use super::*;
use shuck_ast::{Command, CompoundCommand, Position, Span};
use shuck_indexer::Indexer;
use shuck_parser::parser::{Parser, ShellDialect};
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::tempdir;

fn model(source: &str) -> SemanticModel {
    model_with_dialect(source, ShellDialect::Bash)
}

fn model_with_dialect(source: &str, dialect: ShellDialect) -> SemanticModel {
    let output = Parser::with_dialect(source, dialect).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    SemanticModel::build(&output.file, source, &indexer)
}

fn model_with_profile(source: &str, profile: ShellProfile) -> SemanticModel {
    let output = Parser::with_profile(source, profile.clone())
        .parse()
        .unwrap();
    let indexer = Indexer::new(source, &output);
    SemanticModel::build_with_options(
        &output.file,
        source,
        &indexer,
        SemanticBuildOptions {
            shell_profile: Some(profile),
            ..SemanticBuildOptions::default()
        },
    )
}

fn indexed_function(
    index: &WorkspaceCallIndex,
    path: &Path,
    name: &str,
    occurrence: usize,
) -> CallNodeKind {
    let facts = index
        .files()
        .find_map(|(candidate, facts)| (candidate == path).then_some(facts))
        .expect("file should be indexed");
    let definition = facts
        .definitions
        .iter()
        .filter(|definition| definition.name.as_str() == name)
        .nth(occurrence)
        .expect("function definition should be indexed");
    CallNodeKind::Function(definition.identity())
}

fn call_node_name(node: &CallNodeKind) -> Option<&str> {
    match node {
        CallNodeKind::Function(function) => Some(function.name.as_str()),
        CallNodeKind::TopLevel => None,
    }
}

#[test]
fn editor_document_symbols_include_top_level_assignments_and_declarations() {
    let source = "\
VERSION=1
declare -A LOOKUP
declare -n REF=target
build() { :; }
";
    let model = model(source);
    let symbols = model.editor_query().document_symbols();

    assert_eq!(
        symbols
            .iter()
            .map(|symbol| symbol.name.as_str())
            .collect::<Vec<_>>(),
        ["VERSION", "LOOKUP", "REF", "build"]
    );
    assert_eq!(symbols[0].kind, EditorSymbolKind::Variable);
    assert_eq!(symbols[0].range.slice(source), "VERSION");
    assert_eq!(symbols[1].kind, EditorSymbolKind::AssociativeArray);
    assert_eq!(symbols[1].range.slice(source), "LOOKUP");
    assert_eq!(symbols[2].kind, EditorSymbolKind::Declaration);
    assert_eq!(symbols[2].range.slice(source), "REF=target");
    assert_eq!(symbols[3].kind, EditorSymbolKind::Function);
    assert_eq!(symbols[3].selection_span.slice(source), "build");
    assert_eq!(symbols[3].range.slice(source), "build() { :; }\n");
}

#[test]
fn editor_document_symbols_nest_function_locals_loop_variables_and_nested_functions() {
    let source = "\
build() {
  local artifact
  local -n alias=artifact
  for item in \"$@\"; do
    :
  done
  helper() {
    local nested
  }
}
";
    let model = model(source);
    let symbols = model.editor_query().document_symbols();

    assert_eq!(symbols.len(), 1);
    assert_eq!(symbols[0].name.as_str(), "build");
    assert_eq!(
        symbols[0]
            .children
            .iter()
            .map(|symbol| symbol.name.as_str())
            .collect::<Vec<_>>(),
        ["artifact", "alias", "item", "helper"]
    );
    assert_eq!(symbols[0].children[0].kind, EditorSymbolKind::Declaration);
    assert_eq!(symbols[0].children[1].kind, EditorSymbolKind::Declaration);
    assert_eq!(symbols[0].children[1].range.slice(source), "alias=artifact");
    assert_eq!(symbols[0].children[2].kind, EditorSymbolKind::Variable);
    assert_eq!(symbols[0].children[3].kind, EditorSymbolKind::Function);
    assert_eq!(
        symbols[0].children[3]
            .children
            .iter()
            .map(|symbol| symbol.name.as_str())
            .collect::<Vec<_>>(),
        ["nested"]
    );
}

#[test]
fn editor_document_symbols_skip_function_local_plain_assignments() {
    let source = "\
top=1
build() {
  temp=2
  local kept
}
";
    let model = model(source);
    let symbols = model.editor_query().document_symbols();

    assert_eq!(
        symbols
            .iter()
            .map(|symbol| symbol.name.as_str())
            .collect::<Vec<_>>(),
        ["top", "build"]
    );
    assert_eq!(
        symbols[1]
            .children
            .iter()
            .map(|symbol| symbol.name.as_str())
            .collect::<Vec<_>>(),
        ["kept"]
    );
}

#[test]
fn editor_hover_returns_binding_and_reference_symbols() {
    let source = "\
foo=1
echo \"$foo\"
";
    let model = model(source);
    let definition = model
        .editor_query()
        .hover_at_offset(source.find("foo").unwrap())
        .expect("definition should have hover");
    assert_eq!(definition.symbol.name.as_str(), "foo");
    assert_eq!(definition.symbol.kind, EditorSymbolKind::Variable);
    assert_eq!(definition.target_span.slice(source), "foo");
    assert!(!definition.runtime);

    let reference_span = span_for_nth(source, "foo", 1);
    let reference = model
        .editor_query()
        .hover_at_offset(reference_span.start.offset())
        .expect("reference should have hover");
    assert_eq!(reference.symbol.name.as_str(), "foo");
    assert_eq!(reference.symbol.definition_span.slice(source), "foo");
    assert_eq!(reference.target_span, reference_span);
}

#[test]
fn editor_hover_reports_declaration_attributes() {
    let source = "\
build() {
  local -a items
  printf '%s\\n' \"${items[@]}\"
}
";
    let model = model(source);
    let declaration = span_for_nth(source, "items", 0);
    let hover = model
        .editor_query()
        .hover_at_offset(declaration.start.offset())
        .expect("declaration should have hover");
    assert_eq!(hover.symbol.name.as_str(), "items");
    assert_eq!(hover.symbol.kind, EditorSymbolKind::Array);
    assert!(hover.attributes.contains(BindingAttributes::LOCAL));
    assert!(hover.attributes.contains(BindingAttributes::ARRAY));
}

#[test]
fn editor_hover_resolves_function_calls() {
    let source = "\
build() { :; }
build
";
    let model = model(source);
    let call = span_for_nth(source, "build", 1);
    let hover = model
        .editor_query()
        .hover_at_offset(call.start.offset())
        .expect("function call should have hover");
    assert_eq!(hover.symbol.name.as_str(), "build");
    assert_eq!(hover.symbol.kind, EditorSymbolKind::Function);
    assert_eq!(
        hover.symbol.definition_span.slice(source),
        "build() { :; }\n"
    );
    assert_eq!(hover.target_span, call);
    assert_eq!(hover.function_call_count, Some(1));
}

#[test]
fn editor_hover_reports_runtime_names_without_definitions() {
    let source = "printf '%s\\n' \"$HOME\"\n";
    let model = model(source);
    let name = span_for_nth(source, "HOME", 0);
    let hover = model
        .editor_query()
        .hover_at_offset(name.start.offset())
        .expect("runtime name should have hover");
    assert_eq!(hover.symbol.name.as_str(), "HOME");
    assert_eq!(hover.symbol.kind, EditorSymbolKind::RuntimeName);
    assert_eq!(hover.target_span, name);
    assert!(hover.runtime);
    assert!(hover.symbol.binding.is_none());
}

#[test]
fn editor_hover_uses_name_span_inside_parameter_expansions() {
    let source = "\
foo=1
printf '%s\\n' \"${foo:-fallback}\"
printf '%s\\n' \"${created:=fallback}\"
";
    let model = model(source);
    let query = model.editor_query();

    let braced_reference = span_for_nth(source, "foo", 1);
    let hover = query
        .hover_at_offset(braced_reference.start.offset())
        .expect("braced reference name should have hover");
    assert_eq!(hover.symbol.name.as_str(), "foo");
    assert_eq!(hover.target_span, braced_reference);

    let default_assignment = span_for_nth(source, "created", 0);
    let hover = query
        .hover_at_offset(default_assignment.start.offset())
        .expect("default assignment name should have hover");
    assert_eq!(hover.symbol.name.as_str(), "created");
    assert_eq!(hover.target_span, default_assignment);

    for offset in [
        source.find(":-").unwrap(),
        source.find(":=").unwrap(),
        span_for_nth(source, "fallback", 0).start.offset(),
        span_for_nth(source, "fallback", 1).start.offset(),
    ] {
        assert!(query.hover_at_offset(offset).is_none(), "{offset}");
    }
}

#[test]
fn editor_hover_skips_known_runtime_names_that_are_not_active_for_shell() {
    let source = "#!/bin/sh\nprintf '%s\\n' \"$BASH_VERSION\"\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Posix));
    let name = span_for_nth(source, "BASH_VERSION", 0);
    assert!(
        model
            .editor_query()
            .hover_at_offset(name.start.offset())
            .is_none()
    );
}

#[test]
fn editor_hover_ignores_comments_and_single_quoted_text() {
    let source = "\
# foo in a comment
printf '%s\\n' '$foo'
";
    let model = model(source);
    for offset in [source.find("foo").unwrap(), source.rfind("foo").unwrap()] {
        assert!(model.editor_query().hover_at_offset(offset).is_none());
    }
}

#[test]
fn editor_navigation_returns_definitions_references_and_highlights() {
    let source = "\
build() { :; }
name=one
name=two
echo \"$name\"
build
";
    let model = model(source);
    let query = model.editor_query();

    let variable_ref = span_for_nth(source, "name", 2);
    let definitions = query.definition_spans_at_offset(variable_ref.start.offset());
    assert_eq!(
        definitions
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        ["name", "name"]
    );

    let variable_occurrences = query.occurrences_at_offset(variable_ref.start.offset(), true);
    assert_eq!(
        variable_occurrences
            .iter()
            .map(|occurrence| (occurrence.span.slice(source), occurrence.kind))
            .collect::<Vec<_>>(),
        [
            ("name", EditorOccurrenceKind::Write),
            ("name", EditorOccurrenceKind::Write),
            ("name", EditorOccurrenceKind::Read)
        ]
    );

    let function_call = span_for_nth(source, "build", 1);
    assert_eq!(
        query
            .definition_spans_at_offset(function_call.start.offset())
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        ["build() { :; }\n"]
    );
    assert_eq!(
        query
            .occurrences_at_offset(function_call.start.offset(), true)
            .iter()
            .map(|occurrence| (occurrence.span.slice(source), occurrence.kind))
            .collect::<Vec<_>>(),
        [
            ("build", EditorOccurrenceKind::Write),
            ("build", EditorOccurrenceKind::Read)
        ]
    );
}

#[test]
fn call_index_attributes_calls_by_enclosing_function() {
    let source = "\
inner() { :; }
outer() {
  inner
  nested() { inner; }
}
outer
";
    let model = model(source);
    let path = std::path::PathBuf::from("/ws/script.sh");
    let mut index = WorkspaceCallIndex::new();
    index.insert(path.clone(), FileCallFacts::project(&model, Vec::new()));
    let outer = indexed_function(&index, &path, "outer", 0);
    let inner = indexed_function(&index, &path, "inner", 0);

    // `outer` calls `inner` directly once; the `inner` inside `nested` belongs
    // to `nested`, not to `outer`.
    let outgoing = index
        .outgoing(&path, &outer)
        .into_iter()
        .map(|call| {
            (
                call_node_name(&call.node).map(str::to_owned),
                call.call_spans.len(),
            )
        })
        .collect::<Vec<_>>();
    assert_eq!(outgoing, [(Some("inner".to_owned()), 1)]);

    // `inner` is called from both `outer` and the nested `nested` function.
    let mut callers = index
        .incoming(&path, &inner)
        .into_iter()
        .filter_map(|call| call_node_name(&call.node).map(str::to_owned))
        .collect::<Vec<_>>();
    callers.sort();
    assert_eq!(callers, ["nested", "outer"]);

    // `outer` itself is called once, from the script top level.
    let incoming_outer = index.incoming(&path, &outer);
    assert_eq!(incoming_outer.len(), 1);
    assert_eq!(incoming_outer[0].node, CallNodeKind::TopLevel);
    assert_eq!(incoming_outer[0].call_spans.len(), 1);
}

#[test]
fn editor_call_hierarchy_preserves_zsh_multi_name_function_bodies() {
    let source = "function music itunes() { helper; }\nhelper() { :; }\nitunes\n";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let query = model.editor_query();

    let itunes_def = span_for_nth(source, "itunes", 0);
    let itunes = query
        .prepare_call_hierarchy(itunes_def.start.offset())
        .expect("itunes should resolve to a function node");
    let outgoing = query.outgoing_calls(&itunes);
    assert_eq!(outgoing.len(), 1);
    assert_eq!(outgoing[0].to.name.as_str(), "helper");

    let helper_def = span_for_nth(source, "helper", 1);
    let helper = query
        .prepare_call_hierarchy(helper_def.start.offset())
        .expect("helper should resolve to a function node");
    let mut callers = query
        .incoming_calls(&helper)
        .into_iter()
        .map(|call| call.from.name.to_string())
        .collect::<Vec<_>>();
    callers.sort();
    assert_eq!(callers, ["itunes", "music"]);
}

#[test]
fn source_directives_override_dynamic_classification() {
    // source=<path>: assert the target and import symbols only.
    let plain_source = "# shuck: source=lib/util.sh\nsource \"$DIR/util.sh\"\n";
    let plain = model(plain_source);
    let refs = plain.source_refs();
    assert_eq!(refs.len(), 1);
    assert_eq!(refs[0].kind, SourceRefKind::Directive("lib/util.sh".into()));
    assert_eq!(refs[0].path_span.slice(plain_source), "\"$DIR/util.sh\"");
    assert_eq!(
        refs[0]
            .directive_path_span
            .expect("directive path should retain its source span")
            .slice(plain_source),
        "lib/util.sh"
    );
    assert_eq!(
        refs[0].directive,
        Some(SourceDirectiveInfo {
            origin: SourceDirectiveOrigin::Shuck,
            lint: false,
        })
    );

    // lint=true: also lint the target.
    let linted = model("# shuck: source=lib/util.sh lint=true\nsource \"$DIR/util.sh\"\n");
    let refs = linted.source_refs();
    assert_eq!(refs.len(), 1);
    assert_eq!(refs[0].kind, SourceRefKind::Directive("lib/util.sh".into()));
    assert!(refs[0].lints_target());

    // Token order does not matter.
    let reversed = model("# shuck: lint=true source=lib/util.sh\nsource \"$DIR/util.sh\"\n");
    let refs = reversed.source_refs();
    assert_eq!(refs[0].kind, SourceRefKind::Directive("lib/util.sh".into()));
    assert!(refs[0].lints_target());

    // An explicit lint=false reads the same as omitting the flag.
    let unlinted = model("# shuck: source=lib/util.sh lint=false\nsource \"$DIR/util.sh\"\n");
    assert!(!unlinted.source_refs()[0].lints_target());

    // Duplicate keys are deterministic: the first occurrence wins.
    let duplicated =
        model("# shuck: source=lib/util.sh source=other.sh lint=true lint=false\nsource \"$x\"\n");
    let refs = duplicated.source_refs();
    assert_eq!(refs[0].kind, SourceRefKind::Directive("lib/util.sh".into()));
    assert!(refs[0].lints_target());

    // Same-line placement is honored as well.
    let inline = model("source \"$x\"  # shuck: source=lib/util.sh\n");
    assert_eq!(
        inline.source_refs()[0].kind,
        SourceRefKind::Directive("lib/util.sh".into())
    );

    // /dev/null is the explicit no-op include.
    let dev_null = model("# shuck: source=/dev/null\nsource \"$x\"\n");
    assert_eq!(
        dev_null.source_refs()[0].kind,
        SourceRefKind::DirectiveDevNull
    );

    // A lint= flag without a source= target is not a source directive.
    let no_target = model("# shuck: lint=true\nsource \"$DIR/util.sh\"\n");
    assert!(matches!(
        no_target.source_refs()[0].kind,
        SourceRefKind::Dynamic | SourceRefKind::SingleVariableStaticTail { .. }
    ));
}

#[test]
fn shuck_prefixed_comment_still_honors_shellcheck_source() {
    // A `shuck:` comment can explicitly carry a ShellCheck-style hint; the
    // prefix must not swallow it, and the `source=` after `shellcheck` is not
    // read as the native spelling.
    let model = model("# shuck: shellcheck source=lib/util.sh\nsource \"$DIR/util.sh\"\n");
    let refs = model.source_refs();
    assert_eq!(refs.len(), 1);
    assert_eq!(refs[0].kind, SourceRefKind::Directive("lib/util.sh".into()));
    assert_eq!(
        refs[0].directive,
        Some(SourceDirectiveInfo {
            origin: SourceDirectiveOrigin::ShellCheck,
            lint: false,
        })
    );
}

#[test]
fn shellcheck_source_directive_keeps_shellcheck_origin() {
    let model = model("# shellcheck source=lib/util.sh\nsource \"$DIR/util.sh\"\n");
    let refs = model.source_refs();
    assert_eq!(refs.len(), 1);
    assert_eq!(refs[0].kind, SourceRefKind::Directive("lib/util.sh".into()));
    assert_eq!(
        refs[0].directive,
        Some(SourceDirectiveInfo {
            origin: SourceDirectiveOrigin::ShellCheck,
            lint: false,
        }),
        "shellcheck source= is not the shuck-native spelling"
    );
    assert!(!refs[0].has_shuck_directive());
}

#[test]
fn unrelated_shuck_directives_do_not_become_source_directives() {
    // A suppression directive next to a dynamic source must not resolve it.
    let model = model("# shuck: disable=C002\nsource \"$DIR/util.sh\"\n");
    let refs = model.source_refs();
    assert_eq!(refs.len(), 1);
    assert!(matches!(
        refs[0].kind,
        SourceRefKind::Dynamic | SourceRefKind::SingleVariableStaticTail { .. }
    ));
    assert_eq!(refs[0].directive, None);
}

#[test]
fn call_index_honors_binding_visibility_over_name_matching() {
    // `main` calls `grep` before any function of that name is visible; the
    // later local definition must not capture the call, and a sourced file
    // defining `helper` must only receive calls the model left unresolved.
    //
    // The top-level `grep` runs before the function of that name is defined,
    // so it invokes the external command (function-body calls, by contrast,
    // resolve at runtime and may bind to later definitions).
    let caller_source = "\
grep pattern file
grep() { :; }
main() {
  helper
}
";
    let lib_source = "helper() { :; }\n";
    let caller = model(caller_source);
    let lib = model(lib_source);

    let caller_path = std::path::PathBuf::from("/ws/main.sh");
    let lib_path = std::path::PathBuf::from("/ws/lib.sh");
    let mut index = WorkspaceCallIndex::new();
    index.insert(
        caller_path.clone(),
        FileCallFacts::project(&caller, vec![lib_path.clone()]),
    );
    index.insert(lib_path.clone(), FileCallFacts::project(&lib, Vec::new()));
    let grep = indexed_function(&index, &caller_path, "grep", 0);
    let main = indexed_function(&index, &caller_path, "main", 0);
    let helper = indexed_function(&index, &lib_path, "helper", 0);

    // The top level's `grep` call precedes the definition, so it resolves to
    // the external command: no edge from the top level.
    let top_level_outgoing = index.outgoing(&caller_path, &CallNodeKind::TopLevel);
    assert!(
        top_level_outgoing.is_empty(),
        "expected no top-level edges, got {top_level_outgoing:?}"
    );
    // Symmetrically, the local `grep` function has no callers.
    assert!(index.incoming(&caller_path, &grep).is_empty());

    // `main` resolves `helper` through the source edge.
    let outgoing = index
        .outgoing(&caller_path, &main)
        .into_iter()
        .map(|call| {
            (
                call.path.clone(),
                call_node_name(&call.node).map(str::to_owned),
            )
        })
        .collect::<Vec<_>>();
    assert_eq!(outgoing, [(lib_path.clone(), Some("helper".to_owned()))]);
    // And `helper`'s caller is `main` in the sourcing file.
    let incoming = index.incoming(&lib_path, &helper);
    assert_eq!(incoming.len(), 1);
    assert_eq!(incoming[0].path, caller_path);
    assert_eq!(call_node_name(&incoming[0].node), Some("main"));
}

#[test]
fn editor_completion_is_context_aware() {
    let source = "\
build() {
  local inside=1
  printf '%s\\n' \"$\"
}
outside=1
";
    let output = Parser::with_dialect(source, ShellDialect::Bash)
        .parse()
        .unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build(&output.file, source, &indexer);
    let query = model.editor_query();

    let parameter_offset = source.find("\"$").unwrap() + 2;
    let parameter_names = query
        .completions_at_offset(
            source,
            &indexer,
            parameter_offset,
            EditorCompletionOptions {
                include_runtime_names: false,
                include_keywords: true,
            },
        )
        .expect("parameter completion should be available")
        .items
        .into_iter()
        .map(|item| item.name.to_string())
        .collect::<Vec<_>>();
    assert!(parameter_names.contains(&"inside".to_owned()));
    assert!(!parameter_names.contains(&"outside".to_owned()));

    let command_names = query
        .completions_at_offset(
            source,
            &indexer,
            source.len(),
            EditorCompletionOptions::default(),
        )
        .expect("command completion should be available")
        .items
        .into_iter()
        .map(|item| (item.name.to_string(), item.kind))
        .collect::<Vec<_>>();
    assert!(command_names.contains(&("build".to_owned(), EditorCompletionKind::Function)));
    assert!(command_names.contains(&("printf".to_owned(), EditorCompletionKind::Builtin)));
    assert!(command_names.contains(&("if".to_owned(), EditorCompletionKind::Keyword)));
}

#[test]
fn editor_declaration_completion_uses_current_command_segment() {
    for segment in [
        "echo ok; local ex",
        "echo ok && local ex",
        "echo ok || local ex",
    ] {
        let source = format!(
            "\
existing=1
build() {{ {segment}
}}
"
        );
        let output = Parser::with_dialect(&source, ShellDialect::Bash)
            .parse()
            .unwrap();
        let indexer = Indexer::new(&source, &output);
        let model = SemanticModel::build(&output.file, &source, &indexer);
        let offset = source.find("local ex").unwrap() + "local ex".len();
        let names = model
            .editor_query()
            .completions_at_offset(
                &source,
                &indexer,
                offset,
                EditorCompletionOptions::default(),
            )
            .unwrap_or_else(|| panic!("declaration completion should be available for {segment}"))
            .items
            .into_iter()
            .map(|item| item.name.to_string())
            .collect::<Vec<_>>();

        assert!(names.contains(&"existing".to_owned()), "{segment}");
    }
}

#[test]
fn editor_function_completion_retains_the_latest_visible_definition() {
    let source = "dup() { echo first; }\nsource lib.sh\ndup() { echo final; }\ndu";
    let output = Parser::with_dialect(source, ShellDialect::Bash)
        .parse()
        .unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build(&output.file, source, &indexer);
    let completions = model
        .editor_query()
        .completions_at_offset(
            source,
            &indexer,
            source.len(),
            EditorCompletionOptions::default(),
        )
        .expect("command completion should be available");
    assert_eq!(completions.context, EditorCompletionContext::Command);
    let definition = completions
        .items
        .iter()
        .find(|item| item.name.as_str() == "dup")
        .and_then(|item| item.definition_span)
        .expect("duplicate function completion should retain a definition");
    assert!(definition.start.offset() > source.find("source lib.sh").unwrap());
}

#[test]
fn function_local_source_is_visible_to_later_completion_in_the_same_function() {
    let source = "local_scope() {\n  source inner.sh\n  inn\n}\n";
    let model = model(source);
    let source_ref = &model.source_refs()[0];
    let cursor = source.find("inn\n").unwrap() + "inn".len();
    assert!(model.source_ref_visible_at_offset(source_ref, cursor));
}

#[test]
fn editor_completion_is_blocked_at_first_comment_byte() {
    let source = "echo ok\n# comment\n";
    let output = Parser::with_dialect(source, ShellDialect::Bash)
        .parse()
        .unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build(&output.file, source, &indexer);
    let offset = source.find('#').unwrap();

    assert!(
        model
            .editor_query()
            .completions_at_offset(source, &indexer, offset, EditorCompletionOptions::default())
            .is_none()
    );
}

#[test]
fn editor_rename_is_conservative() {
    let source = "\
build() { :; }
name=one
echo \"$name\"
declare -n ref=name
build
";
    let model = model(source);
    let query = model.editor_query();

    let variable_ref = span_for_nth(source, "name", 1);
    let rename = query
        .rename_set_at_offset(variable_ref.start.offset())
        .expect("plain variable should be renameable");
    assert_eq!(rename.name.as_str(), "name");
    assert_eq!(
        rename
            .spans
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        ["name", "name"]
    );

    let function_call = span_for_nth(source, "build", 1);
    let rename = query
        .rename_set_at_offset(function_call.start.offset())
        .expect("function call should be renameable");
    assert_eq!(
        rename
            .spans
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        ["build", "build"]
    );

    let nameref = span_for_nth(source, "ref", 0);
    assert_eq!(
        query.rename_set_at_offset(nameref.start.offset()),
        Err(RenameUnavailable::Nameref)
    );
}

fn span_for_nth(source: &str, needle: &str, index: usize) -> Span {
    let start_offset = source
        .match_indices(needle)
        .nth(index)
        .map(|(offset, _)| offset)
        .unwrap();
    let start = Position::new().advanced_by(&source[..start_offset]);
    Span::from_positions(start, start.advanced_by(needle))
}

fn function_binding_id(model: &SemanticModel, name: &str, index: usize) -> BindingId {
    model.function_definitions(&Name::from(name))[index]
}

fn command_id_starting_with(
    model: &SemanticModel,
    source: &str,
    prefix: &str,
) -> Option<CommandId> {
    model.commands().iter().copied().find(|id| {
        model
            .command_syntax_span(*id)
            .slice(source)
            .starts_with(prefix)
    })
}

fn command_id_containing(model: &SemanticModel, source: &str, needle: &str) -> Option<CommandId> {
    model.commands().iter().copied().find(|id| {
        model
            .command_syntax_span(*id)
            .slice(source)
            .contains(needle)
    })
}

fn model_at_path_with_parse_dialect(path: &Path, dialect: ShellDialect) -> SemanticModel {
    let source = fs::read_to_string(path).unwrap();
    let output = Parser::with_dialect(&source, dialect).parse().unwrap();
    let indexer = Indexer::new(&source, &output);
    let mut observer = NoopTraversalObserver;
    build_with_observer_at_path_with_resolver(
        &output.file,
        &source,
        &indexer,
        &mut observer,
        Some(path),
        None,
    )
}

fn model_at_path(path: &Path) -> SemanticModel {
    model_at_path_with_resolver(path, None)
}

fn model_at_path_with_resolver(
    path: &Path,
    source_path_resolver: Option<&(dyn SourcePathResolver + Send + Sync)>,
) -> SemanticModel {
    let source = fs::read_to_string(path).unwrap();
    let output = Parser::new(&source).parse().unwrap();
    let indexer = Indexer::new(&source, &output);
    let mut observer = NoopTraversalObserver;
    build_with_observer_at_path_with_resolver(
        &output.file,
        &source,
        &indexer,
        &mut observer,
        Some(path),
        source_path_resolver,
    )
}

fn model_at_path_with_plugin_resolver(
    path: &Path,
    plugin_resolver: &(dyn PluginResolver + Send + Sync),
) -> SemanticModel {
    let source = fs::read_to_string(path).unwrap();
    let output = Parser::with_dialect(&source, ShellDialect::Zsh)
        .parse()
        .unwrap();
    let indexer = Indexer::new(&source, &output);
    SemanticModel::build_with_options(
        &output.file,
        &source,
        &indexer,
        SemanticBuildOptions {
            source_path: Some(path),
            plugin_resolver: Some(plugin_resolver),
            shell_profile: Some(ShellProfile::native(shuck_parser::ShellDialect::Zsh)),
            ..SemanticBuildOptions::default()
        },
    )
}

fn reportable_unused_names(model: &SemanticModel) -> Vec<Name> {
    let analysis = model.analysis();
    analysis
        .unused_assignments()
        .iter()
        .filter_map(|binding| {
            let binding = model.binding(*binding);
            matches!(
                binding.kind,
                BindingKind::Assignment
                    | BindingKind::ArrayAssignment
                    | BindingKind::LoopVariable
                    | BindingKind::ReadTarget
                    | BindingKind::MapfileTarget
                    | BindingKind::PrintfTarget
                    | BindingKind::GetoptsTarget
                    | BindingKind::ArithmeticAssignment
            )
            .then_some(binding.name.clone())
        })
        .collect()
}

fn assert_unused_assignment_parity(model: &SemanticModel) {
    let analysis = model.analysis();
    let precise = analysis.unused_assignments().to_vec();
    let exact = analysis
        .dataflow()
        .unused_assignments
        .iter()
        .map(|unused| unused.binding)
        .collect::<Vec<_>>();
    assert_eq!(precise, exact);
}

fn binding_names(model: &SemanticModel, ids: &[BindingId]) -> Vec<String> {
    ids.iter()
        .map(|binding_id| model.binding(*binding_id).name.to_string())
        .collect()
}

fn binding_for_name<'a>(model: &'a SemanticModel, name: &str) -> &'a Binding {
    let ids = model.bindings_for(&Name::from(name));
    assert_eq!(ids.len(), 1, "expected one binding for {name}, got {ids:?}");
    model.binding(ids[0])
}

fn block_with_reference(cfg: &ControlFlowGraph, reference: ReferenceId) -> BlockId {
    cfg.blocks()
        .iter()
        .find(|block| block.references.contains(&reference))
        .map(|block| block.id)
        .expect("reference should be assigned to a CFG block")
}

fn unresolved_names(model: &SemanticModel) -> Vec<String> {
    model
        .unresolved_references()
        .iter()
        .map(|reference| model.reference(*reference).name.to_string())
        .collect()
}

fn uninitialized_names(model: &SemanticModel) -> Vec<String> {
    let analysis = model.analysis();
    let references = analysis
        .uninitialized_references()
        .iter()
        .map(|reference| reference.reference)
        .collect::<Vec<_>>();
    references
        .iter()
        .map(|reference| model.reference(*reference).name.to_string())
        .collect()
}

fn uninitialized_details(model: &SemanticModel) -> Vec<(String, UninitializedCertainty)> {
    let references = model.analysis().uninitialized_references().to_vec();
    references
        .iter()
        .map(|reference| {
            (
                model.reference(reference.reference).name.to_string(),
                reference.certainty,
            )
        })
        .collect()
}

fn assert_names_absent(names: &[&str], actual: &[String]) {
    for name in names {
        assert!(
            !actual.iter().any(|actual_name| actual_name == name),
            "did not expect `{name}` in {actual:?}"
        );
    }
}

fn assert_names_present(names: &[&str], actual: &[String]) {
    for name in names {
        assert!(
            actual.iter().any(|actual_name| actual_name == name),
            "expected `{name}` in {actual:?}"
        );
    }
}

fn arithmetic_read_count(model: &SemanticModel, name: &str) -> usize {
    model
        .references()
        .iter()
        .filter(|reference| {
            reference.kind == ReferenceKind::ArithmeticRead && reference.name == name
        })
        .count()
}

fn arithmetic_write_count(model: &SemanticModel, name: &str) -> usize {
    model
        .bindings()
        .iter()
        .filter(|binding| binding.kind == BindingKind::ArithmeticAssignment && binding.name == name)
        .count()
}

fn assert_arithmetic_usage(
    model: &SemanticModel,
    name: &str,
    expected_reads: usize,
    expected_writes: usize,
) {
    assert_eq!(
        arithmetic_read_count(model, name),
        expected_reads,
        "unexpected arithmetic read count for {name}"
    );
    assert_eq!(
        arithmetic_write_count(model, name),
        expected_writes,
        "unexpected arithmetic write count for {name}"
    );
}

fn common_runtime_source(shebang: &str) -> String {
    format!(
        "{shebang}\nprintf '%s\\n' \"$IFS\" \"$USER\" \"$HOME\" \"$SHELL\" \"$PWD\" \"$TERM\" \"$PATH\" \"$CDPATH\" \"$LANG\" \"$LC_ALL\" \"$LC_TIME\" \"$SUDO_USER\" \"$DOAS_USER\"\n"
    )
}

fn bash_runtime_source(shebang: &str) -> String {
    format!(
        "{shebang}\nprintf '%s\\n' \"$LINENO\" \"$FUNCNAME\" \"${{BASH_SOURCE[0]}}\" \"${{BASH_LINENO[0]}}\" \"$RANDOM\" \"${{BASH_REMATCH[0]}}\" \"$READLINE_LINE\" \"$BASH_VERSION\" \"${{BASH_VERSINFO[0]}}\" \"$OSTYPE\" \"$HISTCONTROL\" \"$HISTSIZE\"\n"
    )
}

fn zsh_runtime_source(shebang: &str) -> String {
    format!(
        "{shebang}\nprintf '%s\\n' \"${{options[xtrace]}}\" \"${{functions[typeset]}}\" \"${{aliases[ls]}}\" \"${{commands[printf]}}\" \"${{parameters[path]}}\" \"${{termcap[ku]}}\" \"${{terminfo[kcuu1]}}\" \"${{path[1]}}\" \"${{pipestatus[1]}}\" \"${{funcstack[1]}}\" \"${{funcfiletrace[1]}}\" \"${{funcsourcetrace[1]}}\" \"${{psvar[1]}}\" \"${{widgets[widget]}}\" \"${{zsh_eval_context[1]}}\" \"${{module_path[1]}}\" \"${{manpath[1]}}\" \"${{mailpath[1]}}\" \"${{historywords[1]}}\" \"${{jobdirs[1]}}\" \"${{jobstates[1]}}\" \"${{jobtexts[1]}}\" \"${{signals[1]}}\" \"${{nameddirs[project]}}\" \"$MATCH\" \"$MBEGIN\" \"$MEND\" \"$BUFFER\" \"$LBUFFER\" \"$RBUFFER\" \"$CURSOR\" \"$WIDGET\" \"$KEYS\" \"$NUMERIC\" \"$POSTDISPLAY\" \"$histchars\" \"$region_highlight\" \"$LINES\" \"$COLUMNS\" \"$ZSH_VERSION\" \"$ZSH_NAME\" \"$ZSH_PATCHLEVEL\" \"$ZSH_SUBSHELL\" \"$ZSH_ARGZERO\"\n"
    )
}

#[test]
fn creates_file_and_function_scopes_and_resolves_local_shadowing() {
    let source = "VAR=global\nf() { local VAR=local; echo $VAR; }\n";
    let model = model(source);

    assert!(matches!(model.scope_kind(ScopeId(0)), ScopeKind::File));
    assert!(model.scopes().iter().any(|scope| {
        matches!(
            &scope.kind,
            ScopeKind::Function(function) if function.contains_name_str("f")
        )
    }));

    let local_binding = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "VAR"
                && matches!(
                    binding.kind,
                    BindingKind::Declaration(DeclarationBuiltin::Local)
                )
        })
        .unwrap();
    assert!(matches!(
        model.scope_kind(local_binding.scope),
        ScopeKind::Function(function) if function.contains_name_str("f")
    ));

    let reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "VAR")
        .unwrap();
    let resolved = model.resolved_binding(reference.id).unwrap();
    assert_eq!(resolved.id, local_binding.id);
}

#[test]
fn brace_fd_redirect_target_resolves_before_new_fd_binding() {
    let source = "\
#!/bin/bash
fd='1 2'
exec {fd}>&$fd
printf '%s\\n' \"$fd\"
";
    let model = model(source);

    let fd_bindings = model.bindings_for(&Name::from("fd"));
    assert_eq!(
        fd_bindings.len(),
        2,
        "expected scalar and brace-fd bindings"
    );
    let initial_binding = model.binding(fd_bindings[0]);
    let brace_fd_binding = model.binding(fd_bindings[1]);
    assert!(
        brace_fd_binding
            .attributes
            .contains(BindingAttributes::INTEGER)
    );

    let fd_refs = model
        .references()
        .iter()
        .filter(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "fd")
        .collect::<Vec<_>>();
    assert_eq!(fd_refs.len(), 2);

    let redirect_target_ref = fd_refs
        .iter()
        .find(|reference| reference.span.start.line() == 3)
        .unwrap();
    let later_ref = fd_refs
        .iter()
        .find(|reference| reference.span.start.line() == 4)
        .unwrap();

    assert_eq!(
        model.resolved_binding(redirect_target_ref.id).unwrap().id,
        initial_binding.id
    );
    assert_eq!(
        model.resolved_binding(later_ref.id).unwrap().id,
        brace_fd_binding.id
    );
}

#[test]
fn declare_plus_g_stays_local_inside_functions() {
    let source = "\
#!/bin/bash
f() {
  declare +g scoped=1
  printf '%s\\n' \"$scoped\"
}
printf '%s\\n' \"$scoped\"
";
    let model = model(source);

    let scoped_binding = binding_for_name(&model, "scoped");
    let ScopeKind::Function(function_scope) = model.scope_kind(scoped_binding.scope) else {
        panic!("expected declare +g binding to live in the function scope");
    };
    assert!(function_scope.contains_name_str("f"));

    let scoped_refs = model
        .references()
        .iter()
        .filter(|reference| {
            reference.kind == ReferenceKind::Expansion && reference.name == "scoped"
        })
        .collect::<Vec<_>>();
    assert_eq!(scoped_refs.len(), 2);

    let inner_ref = scoped_refs
        .iter()
        .find(|reference| reference.span.start.line() == 4)
        .unwrap();
    let outer_ref = scoped_refs
        .iter()
        .find(|reference| reference.span.start.line() == 6)
        .unwrap();

    assert_eq!(
        model.resolved_binding(inner_ref.id).unwrap().id,
        scoped_binding.id
    );
    assert!(model.resolved_binding(outer_ref.id).is_none());
}

#[test]
fn analysis_maps_function_binding_to_body_scope() {
    let model = model(
        "\
x=1
f() {
  :
}
",
    );
    let function_binding = binding_for_name(&model, "f");
    let assignment_binding = binding_for_name(&model, "x");
    let analysis = model.analysis();

    let function_scope = analysis
        .function_scope_for_binding(function_binding.id)
        .expect("expected function body scope for function binding");
    let ScopeKind::Function(function_scope_kind) = model.scope_kind(function_scope) else {
        panic!("expected function binding to map to a function scope");
    };
    assert!(function_scope_kind.contains_name_str("f"));
    assert_eq!(
        analysis.function_scope_for_binding(assignment_binding.id),
        None
    );
}

#[test]
fn zsh_anonymous_functions_create_function_scoped_locals() {
    let source = "function { local scoped=1; echo \"$scoped\" \"$1\"; } arg\necho \"$scoped\"\n";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    let local_binding = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "scoped"
                && matches!(
                    binding.kind,
                    BindingKind::Declaration(DeclarationBuiltin::Local)
                )
        })
        .unwrap();
    let ScopeKind::Function(function_scope) = model.scope_kind(local_binding.scope) else {
        panic!("expected local binding to live in a function scope");
    };
    assert!(function_scope.is_anonymous());

    let scoped_refs = model
        .references()
        .iter()
        .filter(|reference| {
            reference.kind == ReferenceKind::Expansion && reference.name == "scoped"
        })
        .collect::<Vec<_>>();
    assert_eq!(scoped_refs.len(), 2);

    let inner_ref = scoped_refs
        .iter()
        .find(|reference| reference.span.start.line() == 1)
        .unwrap();
    let outer_ref = scoped_refs
        .iter()
        .find(|reference| reference.span.start.line() == 2)
        .unwrap();

    assert_eq!(
        model.resolved_binding(inner_ref.id).unwrap().id,
        local_binding.id
    );
    assert!(model.resolved_binding(outer_ref.id).is_none());
}

#[test]
fn zsh_return_and_exit_status_operands_read_variables() {
    let source = "f() { local ret=1; return ret; }\ncode=2\nexit code\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    assert_eq!(arithmetic_read_count(&model, "ret"), 1);
    assert_eq!(arithmetic_read_count(&model, "code"), 1);
}

#[test]
fn zsh_return_numeric_operand_records_no_reference() {
    let source = "f() { return 1; }\nexit 0\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    assert!(
        model
            .references()
            .iter()
            .all(|reference| reference.kind != ReferenceKind::ArithmeticRead)
    );
}

#[test]
fn bash_return_status_operand_is_not_a_variable_reference() {
    let source = "f() { local ret=1; return ret; }\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Bash));

    assert_eq!(arithmetic_read_count(&model, "ret"), 0);
}

#[test]
fn zsh_nested_expansion_subscript_references_are_source_anchored() {
    let source = "local -a arr names\nlocal i=1\nprint ${${arr[i]}%% *} $names $i\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "i" && reference.span.start.line() > 0)
        .expect("nested arithmetic subscript should reference `i`");
    assert_eq!(reference.span.slice(source), "i");
}

#[test]
fn zsh_nested_expansion_assoc_subscript_key_is_not_a_reference() {
    let source =
        "typeset -A begin\nlocal -a line names\nprint ${${line[${begin[NAMES]},2]}%% *} $names\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    assert!(
        model
            .references()
            .iter()
            .all(|reference| reference.name != "NAMES"),
        "assoc subscript keys are literal words, not variable references"
    );
}

#[test]
fn zsh_multi_name_functions_bind_each_static_alias() {
    let source = "function music itunes() { local track=1; }\n";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    let music_defs = model.function_definitions(&Name::from("music"));
    let itunes_defs = model.function_definitions(&Name::from("itunes"));
    assert_eq!(music_defs.len(), 1);
    assert_eq!(itunes_defs.len(), 1);
    assert_eq!(model.binding(music_defs[0]).span.slice(source), "music");
    assert_eq!(model.binding(itunes_defs[0]).span.slice(source), "itunes");

    let local_binding = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "track"
                && matches!(
                    binding.kind,
                    BindingKind::Declaration(DeclarationBuiltin::Local)
                )
        })
        .unwrap();
    let ScopeKind::Function(function_scope) = model.scope_kind(local_binding.scope) else {
        panic!("expected local binding to live in a function scope");
    };
    assert!(function_scope.contains_name_str("music"));
    assert!(function_scope.contains_name_str("itunes"));
    assert_eq!(function_scope.static_names().len(), 2);
}

#[test]
fn function_definition_binding_lookup_uses_the_command_span() {
    let source = "build() { :; }\nbuild() { echo later; }\n";
    let model = model(source);
    let name = Name::from("build");
    let definitions = model.function_definitions(&name);
    assert_eq!(definitions.len(), 2);

    let function_commands = model
        .commands()
        .iter()
        .copied()
        .filter(|command| model.command_kind(*command) == CommandKind::Function)
        .collect::<Vec<_>>();
    assert_eq!(function_commands.len(), 2);

    for (command, definition) in function_commands
        .into_iter()
        .zip(definitions.iter().copied())
    {
        assert_eq!(
            model.function_definition_binding_for_command_span(model.command_span(command)),
            Some(definition)
        );
    }
}

#[test]
fn binding_lookup_uses_the_definition_span() {
    let source = "foo=1\nbar=2\n";
    let model = model(source);

    for name in [Name::from("foo"), Name::from("bar")] {
        let binding_id = model.bindings_for(&name)[0];
        assert_eq!(
            model.binding_for_definition_span(model.binding(binding_id).span),
            Some(binding_id)
        );
    }
}

#[test]
fn zsh_multi_name_function_lookup_works_through_any_alias() {
    let source = "flag=1\nfunction music itunes() { echo \"$flag\"; }\nitunes\n";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    assert_eq!(model.call_sites_for(&Name::from("itunes")).len(), 1);
    assert!(model.call_graph().reachable.contains(&Name::from("itunes")));
    assert!(
        !reportable_unused_names(&model)
            .into_iter()
            .any(|name| name == "flag")
    );
}

#[test]
fn semantic_analysis_exposes_function_scope_and_call_arity_bindings() {
    let source = "greet ok\ngreet() { echo \"$1\"; }\ngreet\n";
    let model = model(source);
    let analysis = model.analysis();
    let name = Name::from("greet");
    let binding = model.function_definitions(&name)[0];

    let function_scope = analysis
        .function_scope_for_binding(binding)
        .expect("expected function body scope");
    assert!(matches!(
        model.scope_kind(function_scope),
        ScopeKind::Function(_)
    ));

    let arity_sites = analysis
        .function_call_arity_sites(&name)
        .map(|(site, binding_id)| {
            (
                site.name_span.slice(source).to_owned(),
                site.arg_count,
                binding_id,
            )
        })
        .collect::<Vec<_>>();
    assert_eq!(
        arity_sites,
        vec![
            ("greet".to_owned(), 1, binding),
            ("greet".to_owned(), 0, binding)
        ]
    );
}

#[test]
fn semantic_analysis_exposes_case_cli_dispatch_reachability() {
    let source = "\
#!/bin/sh
start() { echo hi; }
case \"$1\" in
  start) \"$1\" ;;
esac
exit $?
late() { echo later; }
";
    let output = Parser::with_dialect(source, ShellDialect::Bash)
        .parse()
        .unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build(&output.file, source, &indexer);
    let analysis = model.analysis();
    let start_binding = model.function_definitions(&Name::from("start"))[0];
    let start_scope = analysis
        .function_scope_for_binding(start_binding)
        .expect("expected start function scope");
    let late_binding = model.function_definitions(&Name::from("late"))[0];
    let late_scope = analysis
        .function_scope_for_binding(late_binding)
        .expect("expected late function scope");

    let dispatches = analysis.case_cli_dispatches(&output.file, source);
    assert_eq!(dispatches.len(), 1);
    assert_eq!(dispatches[0].function_scope(), start_scope);
    assert_eq!(dispatches[0].dispatcher_span().slice(source), "\"$1\"");

    let reachable = analysis.case_cli_reachable_function_scopes(&output.file, &dispatches);
    assert!(reachable.contains(&start_scope));
    assert!(!reachable.contains(&late_scope));
}

#[test]
fn zsh_parameter_modifiers_still_register_references() {
    let model = model_with_profile("print ${(m)foo}\n", ShellProfile::native(ShellDialect::Zsh));
    let unresolved = unresolved_names(&model);

    assert_names_present(&["foo"], &unresolved);
}

#[test]
fn bash_profile_ignores_zsh_parameter_modifier_references() {
    let model = model_with_dialect("printf '%s\\n' ${=zsh_only} ${plain}\n", ShellDialect::Bash);
    let unresolved = unresolved_names(&model);

    assert_names_present(&["plain"], &unresolved);
    assert_names_absent(&["zsh_only"], &unresolved);
}

#[test]
fn zsh_equals_parameter_expansion_registers_reference() {
    let model = model_with_profile(
        "strategies=(${=ZSH_AUTOSUGGEST_STRATEGY})\n",
        ShellProfile::native(ShellDialect::Zsh),
    );
    let unresolved = unresolved_names(&model);

    assert_names_present(&["ZSH_AUTOSUGGEST_STRATEGY"], &unresolved);
}

#[test]
fn zsh_parameter_operations_walk_operand_references_conservatively() {
    let model = model_with_profile(
        "print ${(m)foo#${needle}} ${(S)foo/$pattern/$replacement} ${(m)foo:$offset:${length}}\n",
        ShellProfile::native(ShellDialect::Zsh),
    );
    let unresolved = unresolved_names(&model);

    assert_names_present(
        &[
            "foo",
            "needle",
            "pattern",
            "replacement",
            "offset",
            "length",
        ],
        &unresolved,
    );
}

#[test]
fn zsh_for_loops_bind_all_targets() {
    let source = "\
for key value in a b c d; do
  print -r -- \"$key:$value\"
done
for 1 2 3; do
  print -r -- \"$1|$2|$3\"
done
";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    let loop_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.kind == BindingKind::LoopVariable)
        .map(|binding| {
            (
                binding.name.to_string(),
                binding.span.slice(source).to_string(),
            )
        })
        .collect::<Vec<_>>();
    assert_eq!(
        loop_bindings,
        vec![
            ("key".to_owned(), "key".to_owned()),
            ("value".to_owned(), "value".to_owned()),
            ("1".to_owned(), "1".to_owned()),
            ("2".to_owned(), "2".to_owned()),
            ("3".to_owned(), "3".to_owned()),
        ]
    );

    for name in ["key", "value", "1", "2", "3"] {
        let reference = model
            .references()
            .iter()
            .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == name)
            .unwrap_or_else(|| panic!("expected expansion reference for {name}"));
        let binding = model
            .resolved_binding(reference.id)
            .unwrap_or_else(|| panic!("expected {name} to resolve to a loop binding"));
        assert_eq!(binding.kind, BindingKind::LoopVariable);
        assert_eq!(binding.name, name);
    }
}

#[test]
fn classifies_assignment_and_function_binding_origins() {
    let source = "\
literal=plain
copy=$literal
fallback=${literal:-alt}
lower=${literal,}
quoted=${literal@Q}
name=literal
indirect=${!name}
build() { :; }
";
    let model = model(source);

    assert!(matches!(
        binding_for_name(&model, "literal").origin,
        BindingOrigin::Assignment {
            value: AssignmentValueOrigin::StaticLiteral,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "copy").origin,
        BindingOrigin::Assignment {
            value: AssignmentValueOrigin::PlainScalarAccess,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "fallback").origin,
        BindingOrigin::Assignment {
            value: AssignmentValueOrigin::ParameterOperator,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "lower").origin,
        BindingOrigin::Assignment {
            value: AssignmentValueOrigin::ParameterOperator,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "quoted").origin,
        BindingOrigin::Assignment {
            value: AssignmentValueOrigin::Transformation,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "indirect").origin,
        BindingOrigin::Assignment {
            value: AssignmentValueOrigin::IndirectExpansion,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "build").origin,
        BindingOrigin::FunctionDefinition { .. }
    ));
}

#[test]
fn assignment_definition_span_keeps_quoted_associative_subscript_text() {
    let source = "\
declare -A a
a[\"]=x\"]=1
";
    let model = model(source);
    let binding = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "a" && binding.kind == BindingKind::ArrayAssignment)
        .expect("expected subscripted assignment binding");

    let BindingOrigin::Assignment {
        definition_span, ..
    } = binding.origin
    else {
        panic!("expected assignment origin");
    };
    assert_eq!(definition_span.slice(source), "a[\"]=x\"]");
}

#[test]
fn classifies_loop_and_parameter_default_origins() {
    let source = "\
for size in 16 32; do
  :
done
name=world
for item in $name; do
  :
done
for arg; do
  :
done
: \"${created:=default}\"
";
    let model = model(source);

    assert!(matches!(
        binding_for_name(&model, "size").origin,
        BindingOrigin::LoopVariable {
            items: LoopValueOrigin::StaticWords,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "item").origin,
        BindingOrigin::LoopVariable {
            items: LoopValueOrigin::ExpandedWords,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "arg").origin,
        BindingOrigin::LoopVariable {
            items: LoopValueOrigin::ImplicitArgv,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "created").origin,
        BindingOrigin::ParameterDefaultAssignment { .. }
    ));
}

#[test]
fn classifies_builtin_target_and_imported_origins() {
    let source = "\
read reply
mapfile lines
printf -v rendered '%s' hi
while getopts 'a' opt; do
  :
done
printf '%s\\n' \"$pkgname\"
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build_with_options(
        &output.file,
        source,
        &indexer,
        SemanticBuildOptions {
            file_entry_contract: Some(FileContract {
                required_reads: Vec::new(),
                provided_bindings: vec![ProvidedBinding::new(
                    Name::from("pkgname"),
                    ProvidedBindingKind::Variable,
                    ContractCertainty::Definite,
                )],
                provided_functions: Vec::new(),
                externally_consumed_bindings: false,
                externally_consumed_binding_names: Vec::new(),
                externally_consumed_binding_prefixes: Vec::new(),
            }),
            ..SemanticBuildOptions::default()
        },
    );

    assert!(matches!(
        binding_for_name(&model, "reply").origin,
        BindingOrigin::BuiltinTarget {
            kind: BuiltinBindingTargetKind::Read,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "lines").origin,
        BindingOrigin::BuiltinTarget {
            kind: BuiltinBindingTargetKind::Mapfile,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "rendered").origin,
        BindingOrigin::BuiltinTarget {
            kind: BuiltinBindingTargetKind::Printf,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "opt").origin,
        BindingOrigin::BuiltinTarget {
            kind: BuiltinBindingTargetKind::Getopts,
            ..
        }
    ));
    assert!(matches!(
        binding_for_name(&model, "pkgname").origin,
        BindingOrigin::Imported { .. }
    ));
}

#[test]
fn escaped_declarations_create_name_bindings() {
    let source = "\\typeset result temp_flags\nprintf '%s\\n' \"$result\"\n";
    let model = model(source);
    let unused = model.analysis().unused_assignments().to_vec();

    assert!(!binding_for_name(&model, "result").references.is_empty());
    assert_eq!(binding_names(&model, &unused), vec!["temp_flags"]);
    assert!(matches!(
        binding_for_name(&model, "temp_flags").kind,
        BindingKind::Declaration(DeclarationBuiltin::Typeset)
    ));
}

#[test]
fn zsh_declaration_builtins_with_options_create_initialized_bindings() {
    let source = "\
#!/bin/zsh
typeset -g global_count=1
typeset -gr readonly_global=2
typeset -gi integer_global=3
print $global_count $readonly_global $integer_global
f() {
  local -i local_count=4
  typeset implicit_local=5
  typeset -i implicit_local_integer=6
  typeset -g promoted=7
  integer integer_local=8
  integer +i string_local=9
  print $local_count $implicit_local $implicit_local_integer
  print $promoted $integer_local $string_local
}
f
print $promoted
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let cases = [
        (
            "global_count",
            BindingAttributes::DECLARATION_INITIALIZED,
            BindingAttributes::LOCAL | BindingAttributes::INTEGER | BindingAttributes::READONLY,
        ),
        (
            "readonly_global",
            BindingAttributes::DECLARATION_INITIALIZED | BindingAttributes::READONLY,
            BindingAttributes::LOCAL | BindingAttributes::INTEGER,
        ),
        (
            "integer_global",
            BindingAttributes::DECLARATION_INITIALIZED | BindingAttributes::INTEGER,
            BindingAttributes::LOCAL | BindingAttributes::READONLY,
        ),
        (
            "local_count",
            BindingAttributes::DECLARATION_INITIALIZED
                | BindingAttributes::LOCAL
                | BindingAttributes::INTEGER,
            BindingAttributes::READONLY,
        ),
        (
            "implicit_local",
            BindingAttributes::DECLARATION_INITIALIZED | BindingAttributes::LOCAL,
            BindingAttributes::INTEGER | BindingAttributes::READONLY,
        ),
        (
            "implicit_local_integer",
            BindingAttributes::DECLARATION_INITIALIZED
                | BindingAttributes::LOCAL
                | BindingAttributes::INTEGER,
            BindingAttributes::READONLY,
        ),
        (
            "promoted",
            BindingAttributes::DECLARATION_INITIALIZED,
            BindingAttributes::LOCAL | BindingAttributes::INTEGER | BindingAttributes::READONLY,
        ),
        (
            "integer_local",
            BindingAttributes::DECLARATION_INITIALIZED
                | BindingAttributes::LOCAL
                | BindingAttributes::INTEGER,
            BindingAttributes::READONLY,
        ),
        (
            "string_local",
            BindingAttributes::DECLARATION_INITIALIZED | BindingAttributes::LOCAL,
            BindingAttributes::INTEGER | BindingAttributes::READONLY,
        ),
    ];

    let declaration_names = cases.map(|(name, _, _)| name);

    for (name, required, forbidden) in cases {
        let binding = binding_for_name(&model, name);
        assert!(
            matches!(
                binding.kind,
                BindingKind::Declaration(DeclarationBuiltin::Typeset)
                    | BindingKind::Declaration(DeclarationBuiltin::Local)
            ),
            "expected declaration binding for {name}, got {:?}",
            binding.kind
        );
        assert!(
            binding.attributes.contains(required),
            "expected {name} to contain {required:?}, got {:?}",
            binding.attributes
        );
        assert!(
            !binding.attributes.intersects(forbidden),
            "expected {name} not to contain {forbidden:?}, got {:?}",
            binding.attributes
        );
        assert!(!binding.references.is_empty(), "expected {name} to be read");
    }

    let uninitialized = uninitialized_names(&model);
    assert!(
        declaration_names
            .iter()
            .filter(|name| **name != "promoted")
            .all(|name| !uninitialized
                .iter()
                .any(|uninitialized| uninitialized == name)),
        "zsh declaration reads should resolve, got {uninitialized:?}"
    );

    let unused = binding_names(&model, model.analysis().unused_assignments());
    assert!(
        declaration_names
            .iter()
            .all(|name| !unused.iter().any(|unused| unused == name)),
        "zsh declaration assignments should be live, got {unused:?}"
    );
}

#[test]
fn escaped_exported_declarations_keep_exported_attributes() {
    let source = "\\typeset -x rvm_hook\nrvm_hook=after_update\n";
    let model = model(source);
    let bindings = model.bindings_for(&Name::from("rvm_hook"));

    assert!(
        bindings.iter().any(|binding_id| {
            model
                .binding(*binding_id)
                .attributes
                .contains(BindingAttributes::EXPORTED)
        }),
        "expected an exported rvm_hook declaration"
    );
}

#[test]
fn wrapped_read_creates_read_target_bindings() {
    let source = "builtin read -${flag} 1 -s -r anykey\n";
    let model = model(source);
    let anykey = binding_for_name(&model, "anykey");

    assert!(matches!(anykey.kind, BindingKind::ReadTarget));
    assert!(matches!(
        anykey.origin,
        BindingOrigin::BuiltinTarget {
            kind: BuiltinBindingTargetKind::Read,
            ..
        }
    ));
}

#[test]
fn command_wrapped_printf_v_creates_target_binding() {
    let source = "command printf -v rendered '%s' value\n";
    let model = model(source);
    let rendered = binding_for_name(&model, "rendered");

    assert!(matches!(rendered.kind, BindingKind::PrintfTarget));
    assert!(matches!(
        rendered.origin,
        BindingOrigin::BuiltinTarget {
            kind: BuiltinBindingTargetKind::Printf,
            ..
        }
    ));
}

#[test]
fn zsh_zstyle_query_creates_named_target_binding() {
    let source = "#!/bin/zsh\nzstyle -a ':prezto:load' pmodule-dirs user_pmodule_dirs\nprint $user_pmodule_dirs\n";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let binding = binding_for_name(&model, "user_pmodule_dirs");

    assert_eq!(binding.span.slice(source), "user_pmodule_dirs");
    assert!(matches!(binding.kind, BindingKind::ReadTarget));
    assert!(matches!(
        binding.origin,
        BindingOrigin::BuiltinTarget {
            kind: BuiltinBindingTargetKind::Zstyle,
            ..
        }
    ));
    assert!(binding.attributes.contains(BindingAttributes::ARRAY));
    assert_eq!(binding.references.len(), 1);
}

#[test]
fn zsh_zstyle_scalar_and_boolean_queries_create_named_targets() {
    let source = "\
#!/bin/zsh
zstyle -s ':prezto:load' prompt prompt_theme
zstyle -b ':prezto:load' verbose verbose_enabled
print $prompt_theme $verbose_enabled
";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    for name in ["prompt_theme", "verbose_enabled"] {
        let binding = binding_for_name(&model, name);
        assert!(matches!(binding.kind, BindingKind::ReadTarget));
        assert!(matches!(
            binding.origin,
            BindingOrigin::BuiltinTarget {
                kind: BuiltinBindingTargetKind::Zstyle,
                ..
            }
        ));
        assert!(!binding.attributes.contains(BindingAttributes::ARRAY));
        assert_eq!(binding.references.len(), 1);
    }
}

#[test]
fn zsh_zstyle_listing_mode_does_not_create_named_target() {
    let source = "#!/bin/zsh\nzstyle -L -a ':prezto:load' pmodule-dirs user_pmodule_dirs\nprint $user_pmodule_dirs\n";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    assert!(
        model
            .bindings_for(&Name::from("user_pmodule_dirs"))
            .is_empty()
    );
    assert_eq!(unresolved_names(&model), vec!["user_pmodule_dirs"]);
}

#[test]
fn zsh_zstyle_other_modes_do_not_create_named_targets() {
    for option in ["-g", "-d", "-m", "-t"] {
        let source = format!(
            "#!/bin/zsh\nzstyle {option} -a ':prezto:load' pmodule-dirs user_pmodule_dirs\nprint $user_pmodule_dirs\n"
        );
        let model = model_with_dialect(&source, ShellDialect::Zsh);

        assert!(
            model
                .bindings_for(&Name::from("user_pmodule_dirs"))
                .is_empty(),
            "unexpected target binding for {option}"
        );
        assert_eq!(unresolved_names(&model), vec!["user_pmodule_dirs"]);
    }
}

#[test]
fn zsh_zstyle_array_query_preserves_existing_associative_target() {
    let source = "\
#!/bin/zsh
typeset -A style_map
zstyle -a ':prezto:load' pmodule-dirs style_map
print -r -- ${style_map[key]}
";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let bindings = model.bindings_for(&Name::from("style_map"));
    assert_eq!(bindings.len(), 2);
    let binding = model.binding(*bindings.last().expect("zstyle target binding"));

    assert!(binding.attributes.contains(BindingAttributes::ARRAY));
    assert!(binding.attributes.contains(BindingAttributes::ASSOC));
    assert_names_absent(&["key"], &unresolved_names(&model));
}

#[test]
fn zsh_describe_consumes_named_array_operand() {
    let source =
        "#!/bin/zsh\ncmds=(git:'run git')\n_describe -t commands 'external command' cmds\n";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let binding = binding_for_name(&model, "cmds");

    assert_eq!(binding.references.len(), 1);
    assert_eq!(
        model.reference(binding.references[0]).span.slice(source),
        "cmds"
    );
}

#[test]
fn zsh_describe_consumes_optional_second_array_operand() {
    let source = "\
#!/bin/zsh
values=(git)
descriptions=(git:'run git')
_describe 'external command' values descriptions
";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    for name in ["values", "descriptions"] {
        let binding = binding_for_name(&model, name);
        assert_eq!(binding.references.len(), 1);
        assert_eq!(
            model.reference(binding.references[0]).span.slice(source),
            name
        );
    }
}

#[test]
fn zsh_describe_consumes_array_operands_after_dynamic_description() {
    let source = "\
#!/bin/zsh
desc='external command'
values=(git)
descriptions=(git:'run git')
_describe \"$desc\" values descriptions
";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    for name in ["values", "descriptions"] {
        let binding = binding_for_name(&model, name);
        assert_eq!(binding.references.len(), 1);
        assert_eq!(
            model.reference(binding.references[0]).span.slice(source),
            name
        );
    }
}

#[test]
fn zsh_describe_skips_descriptor_after_dynamic_no_value_options() {
    let source = "\
#!/bin/zsh
opts='-o'
desc=(not:target)
values=(git)
_describe \"$opts\" desc values
";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    assert_eq!(binding_for_name(&model, "desc").references.len(), 0);

    let binding = binding_for_name(&model, "values");
    assert_eq!(binding.references.len(), 1);
    assert_eq!(
        model.reference(binding.references[0]).span.slice(source),
        "values"
    );
}

#[test]
fn zsh_describe_consumes_array_operands_after_group_separator() {
    let source = "\
#!/bin/zsh
values=(git)
more_values=(hg)
more_descriptions=(hg:'run hg')
_describe 'external command' values -- more_values more_descriptions
";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    for name in ["values", "more_values", "more_descriptions"] {
        let binding = binding_for_name(&model, name);
        assert_eq!(binding.references.len(), 1);
        assert_eq!(
            model.reference(binding.references[0]).span.slice(source),
            name
        );
    }
}

#[test]
fn zsh_describe_consumes_named_array_operand_after_options_and_separator() {
    let source = "\
#!/bin/zsh
cmds=(git:'run git')
other_cmds=(hg:'run hg')
_describe -O 'external command' cmds
_describe -- 'other command' other_cmds
";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    for name in ["cmds", "other_cmds"] {
        let binding = binding_for_name(&model, name);
        assert_eq!(binding.references.len(), 1);
        assert_eq!(
            model.reference(binding.references[0]).span.slice(source),
            name
        );
    }
}

#[test]
fn shflags_define_commands_create_flag_bindings() {
    let source = "DEFINE_boolean show_commands false 'show commands' s\n";
    let model = model(source);
    let binding = binding_for_name(&model, "FLAGS_show_commands");

    assert_eq!(binding.span.slice(source), "show_commands");
    assert!(matches!(
        binding.kind,
        BindingKind::Declaration(DeclarationBuiltin::Declare)
    ));
}

#[test]
fn isolates_subshell_bindings_from_parent_resolution() {
    let source = "VAR=outer\n( VAR=inner )\necho $VAR\n";
    let model = model(source);

    let reference = model.references().last().unwrap();
    let binding = model.resolved_binding(reference.id).unwrap();
    assert_eq!(binding.span.slice(source), "VAR");
}

#[test]
fn declare_g_in_command_substitution_stays_in_that_execution_scope() {
    let source = "\
#!/bin/bash
printf '%s\\n' \"$(
  f() { declare -gA assoc=([key]=1); }
  f
  printf '%s\\n' \"${assoc[key]}\"
)\"
printf '%s\\n' \"${assoc[key]}\"
";
    let model = model(source);

    let assoc_binding = binding_for_name(&model, "assoc");
    assert!(assoc_binding.attributes.contains(BindingAttributes::ARRAY));
    assert!(assoc_binding.attributes.contains(BindingAttributes::ASSOC));
    assert!(matches!(
        model.scope_kind(assoc_binding.scope),
        ScopeKind::CommandSubstitution
    ));
}

#[test]
fn records_pipeline_segment_scopes() {
    let source = "a | b | c\n";
    let model = model(source);

    let pipeline_scopes = model
        .scopes()
        .iter()
        .filter(|scope| matches!(scope.kind, ScopeKind::Pipeline))
        .count();
    assert_eq!(pipeline_scopes, 3);
}

#[test]
fn zsh_final_pipeline_segment_uses_enclosing_scope() {
    let source = "\
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let pipeline_assignment = model
        .bindings()
        .iter()
        .find(|binding| binding.span.start.offset() == source.find("value=new").unwrap())
        .unwrap();
    assert!(!matches!(
        model.scope_kind(pipeline_assignment.scope),
        ScopeKind::Pipeline
    ));
}

#[test]
fn zsh_sh_emulation_keeps_pipeline_tail_in_pipeline_scope() {
    let source = "\
emulate sh
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let pipeline_assignment = model
        .bindings()
        .iter()
        .find(|binding| binding.span.start.offset() == source.find("value=new").unwrap())
        .unwrap();
    assert!(matches!(
        model.scope_kind(pipeline_assignment.scope),
        ScopeKind::Pipeline
    ));
}

#[test]
fn zsh_initial_sh_emulation_keeps_pipeline_tail_in_pipeline_scope() {
    let source = "\
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
    let model = model_with_profile(
        source,
        ShellProfile::with_zsh_options(
            ShellDialect::Zsh,
            ZshOptionState::for_emulate(ZshEmulationMode::Sh),
        ),
    );

    let pipeline_assignment = model
        .bindings()
        .iter()
        .find(|binding| binding.span.start.offset() == source.find("value=new").unwrap())
        .unwrap();
    assert!(matches!(
        model.scope_kind(pipeline_assignment.scope),
        ScopeKind::Pipeline
    ));
}

#[test]
fn zsh_compound_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
    let source = "\
{ emulate sh; }
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let pipeline_assignment = model
        .bindings()
        .iter()
        .find(|binding| binding.span.start.offset() == source.find("value=new").unwrap())
        .unwrap();
    assert!(matches!(
        model.scope_kind(pipeline_assignment.scope),
        ScopeKind::Pipeline
    ));
}

#[test]
fn zsh_if_condition_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
    let source = "\
if emulate sh; then :; fi
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let pipeline_assignment = model
        .bindings()
        .iter()
        .find(|binding| binding.span.start.offset() == source.find("value=new").unwrap())
        .unwrap();
    assert!(matches!(
        model.scope_kind(pipeline_assignment.scope),
        ScopeKind::Pipeline
    ));
}

#[test]
fn zsh_logical_left_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
    let source = "\
emulate sh && :
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let pipeline_assignment = model
        .bindings()
        .iter()
        .find(|binding| binding.span.start.offset() == source.find("value=new").unwrap())
        .unwrap();
    assert!(matches!(
        model.scope_kind(pipeline_assignment.scope),
        ScopeKind::Pipeline
    ));
}

#[test]
fn zsh_pipeline_tail_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
    let source = "\
print setup | emulate sh
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let pipeline_assignment = model
        .bindings()
        .iter()
        .find(|binding| binding.span.start.offset() == source.find("value=new").unwrap())
        .unwrap();
    assert!(matches!(
        model.scope_kind(pipeline_assignment.scope),
        ScopeKind::Pipeline
    ));
}

#[test]
fn zsh_always_body_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
    let source = "\
{ emulate sh; } always { :; }
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let pipeline_assignment = model
        .bindings()
        .iter()
        .find(|binding| binding.span.start.offset() == source.find("value=new").unwrap())
        .unwrap();
    assert!(matches!(
        model.scope_kind(pipeline_assignment.scope),
        ScopeKind::Pipeline
    ));
}

#[test]
fn zsh_always_cleanup_sh_emulation_keeps_later_pipeline_tail_in_pipeline_scope() {
    let source = "\
{ :; } always { emulate sh; }
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let pipeline_assignment = model
        .bindings()
        .iter()
        .find(|binding| binding.span.start.offset() == source.find("value=new").unwrap())
        .unwrap();
    assert!(matches!(
        model.scope_kind(pipeline_assignment.scope),
        ScopeKind::Pipeline
    ));
}

#[test]
fn zsh_emulation_restores_native_pipeline_tail_scope() {
    let source = "\
emulate sh
emulate zsh
value=old
printf '%s\\n' x | value=new
print -r -- \"$value\"
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let pipeline_assignment = model
        .bindings()
        .iter()
        .find(|binding| binding.span.start.offset() == source.find("value=new").unwrap())
        .unwrap();
    assert!(!matches!(
        model.scope_kind(pipeline_assignment.scope),
        ScopeKind::Pipeline
    ));
}

#[test]
fn zsh_nonfinal_pipeline_segments_keep_pipeline_scope() {
    let source = "\
value=old
{ value=left; print x; } | { value=middle; print y; } | cat
print -r -- \"$value\"
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    for assignment in ["value=left", "value=middle"] {
        let binding = model
            .bindings()
            .iter()
            .find(|binding| binding.span.start.offset() == source.find(assignment).unwrap())
            .unwrap();
        assert!(matches!(
            model.scope_kind(binding.scope),
            ScopeKind::Pipeline
        ));
    }
}

#[test]
fn indexed_scope_lookup_matches_linear_scan_for_all_offsets() {
    let source = "\
outer() {
  local current=1
  (
    printf '%s\\n' \"$(
      printf '%s\\n' \"$current\" | tr a b
    )\"
  )
  inner() { echo \"$current\"; }
}
outer
";
    let model = model(source);

    for offset in 0..=source.len() {
        assert_eq!(
            model.scope_at(offset),
            linear_scope_at(model.scopes(), offset),
            "offset {offset}"
        );
    }
}

#[test]
fn command_topology_exposes_parent_child_relationships() {
    let source = "if foo; then bar | baz; fi\n";
    let model = model(source);

    let if_id = command_id_starting_with(&model, source, "if foo").unwrap();
    let foo_id = command_id_starting_with(&model, source, "foo").unwrap();
    let pipeline_id = command_id_containing(&model, source, "bar | baz").unwrap();
    let bar_id = command_id_starting_with(&model, source, "bar").unwrap();
    let baz_id = command_id_starting_with(&model, source, "baz").unwrap();

    assert_eq!(model.command_parent_id(foo_id), Some(if_id));
    assert_eq!(model.command_parent_id(pipeline_id), Some(if_id));
    assert_eq!(model.command_parent_id(bar_id), Some(pipeline_id));
    assert_eq!(model.command_parent_id(baz_id), Some(pipeline_id));
    assert!(model.command_children(if_id).contains(&foo_id));
    assert!(model.command_children(if_id).contains(&pipeline_id));
}

#[test]
fn command_topology_attaches_nested_function_body_to_nearest_function() {
    let source = "\
outer() {
  inner() {
    echo inner
  }
}
";
    let model = model(source);

    let inner_function_id = command_id_starting_with(&model, source, "inner()").unwrap();
    let inner_body_id = model
        .commands()
        .iter()
        .copied()
        .filter(|id| {
            model.command_kind(*id) == CommandKind::Compound(CompoundCommandKind::BraceGroup)
                && model
                    .command_syntax_span(*id)
                    .slice(source)
                    .contains("echo inner")
        })
        .min_by_key(|id| {
            let span = model.command_syntax_span(*id);
            span.end.offset() - span.start.offset()
        })
        .unwrap();

    assert_eq!(
        model.command_parent_id(inner_body_id),
        Some(inner_function_id)
    );
    assert!(
        model
            .command_children(inner_function_id)
            .contains(&inner_body_id)
    );
}

#[test]
fn command_topology_excludes_nested_substitutions_from_structural_commands() {
    let source = "echo \"$(printf '%s\\n' hi)\"\n";
    let model = model(source);

    let echo_id = command_id_starting_with(&model, source, "echo").unwrap();
    let printf_id = command_id_starting_with(&model, source, "printf").unwrap();

    assert_eq!(model.command_parent_id(printf_id), Some(echo_id));
    assert!(model.structural_commands().contains(&echo_id));
    assert!(!model.structural_commands().contains(&printf_id));
}

#[test]
fn command_topology_exposes_syntax_backed_commands_in_source_order() {
    let source = "echo \"$(printf '%s\\n' hi)\"\nif probe; then done_cmd; fi\n";
    let model = model(source);

    let ordered = model.commands_in_source_order();
    assert_eq!(ordered.len(), model.commands().len());
    assert!(model.commands().iter().all(|id| ordered.contains(id)));

    for ids in ordered.windows(2) {
        let left = model.command_syntax_span(ids[0]);
        let right = model.command_syntax_span(ids[1]);
        assert!(
            left.start.offset() < right.start.offset()
                || (left.start.offset() == right.start.offset()
                    && left.end.offset() >= right.end.offset()),
            "commands are out of source order: {left:?} before {right:?}"
        );
    }
}

#[test]
fn command_topology_skips_synthetic_parents_for_syntax_backed_queries() {
    let source = "case \"$x\" in $(echo \"$v\")) ;; esac\n";
    let model = model(source);

    let case_id = command_id_starting_with(&model, source, "case").unwrap();
    let echo_id = command_id_starting_with(&model, source, "echo").unwrap();

    assert_eq!(
        model.syntax_backed_command_parent_id(echo_id),
        Some(case_id)
    );
    assert!(
        model
            .syntax_backed_command_children(case_id)
            .contains(&echo_id)
    );
}

#[test]
fn command_lookup_by_span_and_kind_skips_synthetic_commands() {
    let source = "case \"$x\" in $(echo a)) ;; esac\n";
    let model = model(source);

    let case_span = model
        .commands()
        .iter()
        .copied()
        .map(|id| model.command_syntax_span(id))
        .find(|span| span.slice(source).starts_with("case"))
        .unwrap();

    assert!(
        model
            .command_by_span_and_kind(case_span, CommandKind::Compound(CompoundCommandKind::Case))
            .is_some()
    );
    let case_id = model.command_by_span(case_span).unwrap();
    assert_eq!(
        model.command_kind(case_id),
        CommandKind::Compound(CompoundCommandKind::Case)
    );
    let innermost_case_id = model.innermost_command_id_at(source.find("case").unwrap());
    assert_eq!(innermost_case_id, Some(case_id));
}

#[test]
fn commands_iter_filters_synthetic_ids_so_command_kind_is_safe() {
    let source = "case \"$x\" in $(echo a)) ;; esac\n";
    let model = model(source);

    for id in model.commands().iter().copied() {
        let _ = model.command_kind(id);
    }
}

#[test]
fn command_topology_preserves_nested_region_immediate_parents() {
    let source = "echo >\"$(a | b)\"\n";
    let model = model(source);

    let echo_id = command_id_starting_with(&model, source, "echo").unwrap();
    let pipeline_span = model.pipeline_commands()[0].span;
    let pipeline_id = model
        .command_by_span_and_kind(pipeline_span, CommandKind::Binary)
        .unwrap();
    let a_id = command_id_starting_with(&model, source, "a").unwrap();
    let b_id = command_id_starting_with(&model, source, "b").unwrap();

    assert_eq!(model.command_parent_id(pipeline_id), Some(echo_id));
    assert_eq!(model.command_parent_id(a_id), Some(pipeline_id));
    assert_eq!(model.command_parent_id(b_id), Some(pipeline_id));
}

#[test]
fn command_topology_finds_innermost_command_at_offsets() {
    let source = "if foo; then bar; fi\n";
    let model = model(source);

    let if_id = command_id_starting_with(&model, source, "if foo").unwrap();
    let foo_id = command_id_starting_with(&model, source, "foo").unwrap();
    let bar_id = command_id_starting_with(&model, source, "bar").unwrap();

    assert_eq!(
        model.innermost_command_id_at(source.find("if").unwrap()),
        Some(if_id)
    );
    assert_eq!(
        model.innermost_command_id_at(source.find("foo").unwrap()),
        Some(foo_id)
    );
    assert_eq!(
        model.innermost_command_id_at(source.find("bar").unwrap()),
        Some(bar_id)
    );
}

#[test]
fn command_topology_finds_innermost_command_containing_arbitrary_offsets() {
    let source = "if foo; then bar; fi\n";
    let model = model(source);

    let if_id = command_id_starting_with(&model, source, "if foo").unwrap();
    let foo_id = command_id_starting_with(&model, source, "foo").unwrap();
    let bar_id = command_id_starting_with(&model, source, "bar").unwrap();

    assert_eq!(
        model.innermost_command_id_containing_offset(source.find("if").unwrap() + 1),
        Some(if_id)
    );
    assert_eq!(
        model.innermost_command_id_containing_offset(source.find("foo").unwrap() + 1),
        Some(foo_id)
    );
    assert_eq!(
        model.innermost_command_id_containing_offset(source.find("bar").unwrap() + 1),
        Some(bar_id)
    );
}

#[test]
fn command_topology_keeps_syntax_and_statement_containment_separate() {
    let source = "if foo; then bar; fi >out\n";
    let model = model(source);

    let if_id = command_id_starting_with(&model, source, "if foo").unwrap();
    let redirect_target_offset = source.find("out").unwrap() + 2;

    assert_eq!(model.innermost_command_id_at(redirect_target_offset), None);
    assert_eq!(
        model.innermost_command_id_containing_offset(redirect_target_offset),
        Some(if_id)
    );
}

#[test]
fn arithmetic_plain_assignment_is_write_only() {
    let model = model("(( i = 0 ))\n");
    assert_arithmetic_usage(&model, "i", 0, 1);
}

#[test]
fn arithmetic_compound_assignment_is_read_write() {
    let model = model("(( i += 2 ))\n");
    assert_arithmetic_usage(&model, "i", 1, 1);
}

#[test]
fn arithmetic_prefix_update_is_read_write() {
    let model = model("(( ++i ))\n");
    assert_arithmetic_usage(&model, "i", 1, 1);
}

#[test]
fn arithmetic_postfix_update_is_read_write() {
    let model = model("(( i++ ))\n");
    assert_arithmetic_usage(&model, "i", 1, 1);
}

#[test]
fn arithmetic_assignment_reads_index_expressions() {
    let model = model("(( a[i++] = 1 ))\n");
    assert_arithmetic_usage(&model, "a", 0, 1);
    assert_arithmetic_usage(&model, "i", 1, 1);
}

#[test]
fn arithmetic_conditional_tracks_branch_reads_and_writes() {
    let model = model("(( x ? y++ : (z = 1) ))\n");
    assert_arithmetic_usage(&model, "x", 1, 0);
    assert_arithmetic_usage(&model, "y", 1, 1);
    assert_arithmetic_usage(&model, "z", 0, 1);
}

#[test]
fn arithmetic_comma_walks_each_expression_in_order() {
    let model = model("(( x = 1, y += x, z ))\n");
    assert_arithmetic_usage(&model, "x", 1, 1);
    assert_arithmetic_usage(&model, "y", 1, 1);
    assert_arithmetic_usage(&model, "z", 1, 0);
}

#[test]
fn arithmetic_shell_words_still_walk_nested_expansions() {
    let model = model("echo $(( $(printf '%s' \"$x\") + y ))\n");
    assert!(
        model.references().iter().any(|reference| {
            reference.kind == ReferenceKind::Expansion && reference.name == "x"
        })
    );
    assert_arithmetic_usage(&model, "y", 1, 0);
}

#[test]
fn substring_offset_arithmetic_tracks_postfix_updates() {
    let source = "\
#!/bin/bash
spinner() {
  local chars=\"/-\\\\|\"
  local spin_i=0
  while true; do
    printf '%s\\n' \"${chars:spin_i++%${#chars}:1}\"
  done
}
";
    let model = model(source);
    assert!(model.references().iter().any(|reference| {
        reference.kind == ReferenceKind::ParameterSliceArithmetic && reference.name == "spin_i"
    }));
    assert_eq!(
        arithmetic_write_count(&model, "spin_i"),
        1,
        "unexpected arithmetic write count for spin_i"
    );

    let unused = model
        .analysis()
        .unused_assignments()
        .iter()
        .map(|binding| model.binding(*binding).name.as_str())
        .collect::<Vec<_>>();
    assert!(!unused.contains(&"spin_i"), "unused bindings: {:?}", unused);
}

#[test]
fn classifies_nameref_and_source_directives() {
    let source = "\
declare -n ref=target
# shellcheck source=lib.sh
source \"$x\"
";
    let model = model(source);

    let nameref = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "ref")
        .unwrap();
    assert!(matches!(nameref.kind, BindingKind::Nameref));
    assert!(nameref.attributes.contains(BindingAttributes::NAMEREF));

    assert_eq!(
        model.source_refs()[0].kind,
        SourceRefKind::Directive("lib.sh".into())
    );
    assert_eq!(
        model.source_refs()[0].resolution,
        SourceRefResolution::Unchecked
    );
}

#[test]
fn source_directive_applies_across_contiguous_own_line_comments() {
    let source = "\
# shellcheck source=lib.sh
# shellcheck disable=SC2154
source \"$x\"
";
    let model = model(source);

    assert_eq!(
        model.source_refs()[0].kind,
        SourceRefKind::Directive("lib.sh".into())
    );
}

#[test]
fn dev_null_source_directive_persists_until_overridden() {
    let source = "\
# shellcheck source=/dev/null
foo() { echo hi; }
source \"$a\"
source \"$b\"
# shellcheck source=./helper.sh
source \"$c\"
source \"$d\"
";
    let model = model(source);

    assert_eq!(model.source_refs().len(), 4);
    assert_eq!(model.source_refs()[0].kind, SourceRefKind::DirectiveDevNull);
    assert_eq!(model.source_refs()[1].kind, SourceRefKind::DirectiveDevNull);
    assert_eq!(
        model.source_refs()[2].kind,
        SourceRefKind::Directive("./helper.sh".into())
    );
    assert_eq!(model.source_refs()[3].kind, SourceRefKind::Dynamic);
}

#[test]
fn escaped_dot_source_builtin_still_records_dynamic_source_refs() {
    let source = "\
#!/bin/bash
\\. \"$rvm_environments_path/$1\"
";
    let model = model(source);

    assert_eq!(model.source_refs().len(), 1);
    assert_eq!(model.source_refs()[0].kind, SourceRefKind::Dynamic);
    assert_eq!(
        model.source_refs()[0].diagnostic_class,
        SourceRefDiagnosticClass::DynamicPath
    );
}

#[test]
fn command_wrappers_do_not_create_source_refs() {
    let source = "\
#!/bin/bash
command . \"$file\"
builtin source \"$file\"
noglob . \"$file\"
";
    let model = model(source);

    assert!(model.source_refs().is_empty());
}

#[test]
fn parameter_expansion_roots_with_static_path_tails_are_untracked_source_refs() {
    for source in [
        "#!/bin/bash\nsource \"${ROOT?}/helper.sh\"\n",
        "#!/bin/bash\nsource \"${ROOT:-$HOME/.config}/helper.sh\"\n",
        "#!/bin/bash\nsource \"${ROOT+vendor}/helper.sh\"\n",
        "#!/bin/bash\nsource \"${#ROOT}/helper.sh\"\n",
        "#!/bin/bash\nsource \"${ROOT%/*}/helper.sh\"\n",
        "#!/bin/bash\nsource \"${ROOT/file/repl}/helper.sh\"\n",
        "#!/bin/bash\nsource \"${!ROOT}/helper.sh\"\n",
    ] {
        let model = model(source);

        assert_eq!(model.source_refs().len(), 1, "{source}");
        assert_eq!(
            model.source_refs()[0].kind,
            SourceRefKind::Dynamic,
            "{source}"
        );
        assert_eq!(
            model.source_refs()[0].diagnostic_class,
            SourceRefDiagnosticClass::UntrackedFile,
            "{source}"
        );
    }
}

#[test]
fn command_substitution_roots_with_static_path_tails_are_untracked_source_refs() {
    for source in [
        "#!/bin/bash\nsource \"$(git --exec-path)/git-sh-setup\"\n",
        "#!/bin/sh\n. \"$(dirname \"$0\")/autopause-fcns.sh\"\n",
        "#!/bin/ksh\nsource \"$(cd \"$(dirname \"${0}\")\"; pwd)/../nb\"\n",
    ] {
        let model = model(source);

        assert_eq!(model.source_refs().len(), 1, "{source}");
        assert_eq!(
            model.source_refs()[0].kind,
            SourceRefKind::Dynamic,
            "{source}"
        );
        assert_eq!(
            model.source_refs()[0].diagnostic_class,
            SourceRefDiagnosticClass::UntrackedFile,
            "{source}"
        );
    }
}

#[test]
fn literal_leading_backslashes_do_not_create_source_refs() {
    for source in [
        "#!/bin/bash\n\"\\\\.\" \"$rvm_environments_path/$1\"\n",
        "#!/bin/bash\n'\\source' \"$rvm_environments_path/$1\"\n",
        "#!/bin/bash\n\\\\. \"$rvm_environments_path/$1\"\n",
    ] {
        let model = model(source);

        assert!(
            model.source_refs().is_empty(),
            "unexpected source refs for {source:?}: {:?}",
            model.source_refs()
        );
    }
}

#[test]
fn builds_transitive_call_graph_and_overwritten_functions() {
    let source = "\
f() { g; }
g() { echo hi; }
f
f() { echo again; }
";
    let model = model(source);

    assert!(model.call_graph().reachable.contains("f"));
    assert!(model.call_graph().reachable.contains("g"));
    assert_eq!(model.call_graph().overwritten.len(), 1);
    assert_eq!(model.call_graph().overwritten[0].name, "f");
}

#[test]
fn call_graph_does_not_root_calls_inside_uncalled_functions() {
    let source = "\
f() { g; }
g() { echo hi; }
";
    let model = model(source);

    assert!(!model.call_graph().reachable.contains("f"));
    assert!(!model.call_graph().reachable.contains("g"));
}

#[test]
fn precise_overwritten_functions_track_real_overwrites() {
    let source = "\
f() { echo hi; }
f() { echo again; }
";
    let model = model(source);
    let analysis = model.analysis();
    let overwritten = analysis.overwritten_functions();

    assert_eq!(overwritten.len(), 1);
    assert_eq!(overwritten[0].name, "f");
    assert!(!overwritten[0].first_called);
}

#[test]
fn precise_overwritten_functions_preserve_calls_before_redefinition() {
    let source = "\
f() { echo hi; }
f
f() { echo again; }
";
    let model = model(source);
    let analysis = model.analysis();
    let overwritten = analysis.overwritten_functions();

    assert_eq!(overwritten.len(), 1);
    assert!(overwritten[0].first_called);
}

#[test]
fn precise_overwritten_functions_count_nested_calls_from_invoked_wrappers() {
    let source = "\
run_case() {
  helper
}
helper() { echo hi; }
run_case
helper() { echo again; }
";
    let model = model(source);
    let analysis = model.analysis();
    let overwritten = analysis.overwritten_functions();

    assert_eq!(overwritten.len(), 1);
    assert!(overwritten[0].first_called);
}

#[test]
fn precise_overwritten_functions_do_not_count_shadowed_nested_calls() {
    let source = "\
run_case() {
  helper() { echo local; }
  helper
}
helper() { echo hi; }
run_case
helper() { echo again; }
";
    let model = model(source);
    let analysis = model.analysis();
    let overwritten = analysis.overwritten_functions();

    assert_eq!(overwritten.len(), 1);
    assert!(!overwritten[0].first_called);
}

#[test]
fn precise_overwritten_functions_ignore_mutually_exclusive_branches() {
    let source = "\
if cond; then
  helper() { return 0; }
else
  helper() { return 1; }
fi
helper
";
    let model = model(source);

    assert!(model.analysis().overwritten_functions().is_empty());
}

#[test]
fn precise_overwritten_functions_ignore_conditional_redefinition_after_default() {
    let source = "\
helper() { return 0; }
if cond; then
  helper() { return 1; }
fi
helper
";
    let model = model(source);

    assert!(model.analysis().overwritten_functions().is_empty());
}

#[test]
fn precise_overwritten_functions_allow_terminating_paths_before_redefinition() {
    let source = "\
helper() { return 0; }
maybe || exit 1
helper() { return 1; }
";
    let model = model(source);
    let analysis = model.analysis();
    let overwritten = analysis.overwritten_functions();

    assert_eq!(overwritten.len(), 1);
    assert_eq!(overwritten[0].name, "helper");
    assert!(!overwritten[0].first_called);
}

#[test]
fn precise_overwritten_functions_do_not_merge_distinct_helper_scopes() {
    let source = "\
factory_one() {
  helper() { return 0; }
  helper
}
factory_two() {
  helper() { return 1; }
  helper
}
factory_one
factory_two
";
    let model = model(source);

    assert!(model.analysis().overwritten_functions().is_empty());
}

#[test]
fn unreached_functions_report_definitions_before_script_termination() {
    let source = "f() { echo hi; }\nexit 0\n";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions();

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "f");
    assert_eq!(
        unreached[0].reason,
        UnreachedFunctionReason::ScriptTerminates
    );
}

#[test]
fn unreached_functions_report_definitions_before_terminating_function_call() {
    let source = "\
f() { echo hi; }
die() { exit 0; }
die
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions();

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "f");
    assert_eq!(
        unreached[0].reason,
        UnreachedFunctionReason::ScriptTerminates
    );
}

#[test]
fn unreached_functions_ignore_definitions_at_plain_eof() {
    let source = "f() { echo hi; }\n";
    let model = model(source);

    assert!(model.analysis().unreached_functions().is_empty());
}

#[test]
fn unreached_functions_report_nested_plain_eof_only_with_compat_option() {
    let source = "\
outer() {
  inner() { :; }
}
";
    let model = model(source);
    let analysis = model.analysis();

    assert!(analysis.unreached_functions().is_empty());

    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "inner");
    assert_eq!(
        unreached[0].reason,
        UnreachedFunctionReason::EnclosingFunctionUnreached
    );
}

#[test]
fn unreached_functions_report_subshell_definitions_with_compat_option() {
    let source = "( inner() { :; }; )\n";
    let model = model(source);
    let analysis = model.analysis();

    assert!(analysis.unreached_functions().is_empty());

    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "inner");
    assert_eq!(
        unreached[0].reason,
        UnreachedFunctionReason::EnclosingFunctionUnreached
    );
}

#[test]
fn unreached_functions_ignore_nested_definition_when_enclosing_function_is_called() {
    let source = "\
outer() {
  inner() { :; }
}
outer
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert!(unreached.is_empty());
}

#[test]
fn unreached_functions_do_not_count_indirect_enclosing_function_calls() {
    let source = "\
outer() {
  inner() { :; }
}
name=outer
$name
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "inner");
}

#[test]
fn unreached_functions_count_same_scope_nested_calls_before_enclosing_scope_exits() {
    let source = "\
outer() {
  inner() { :; }
  inner
}
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert!(unreached.is_empty());
}

#[test]
fn unreached_functions_count_same_scope_command_substitution_calls() {
    let source = "\
outer() {
  inner() { :; }
  value=$(inner)
}
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert!(unreached.is_empty());
}

#[test]
fn unreached_functions_ignore_inner_shadow_inside_command_substitution() {
    let source = "\
outer() {
  inner() { :; }
  value=$(inner() { :; }; inner)
}
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "inner");
}

#[test]
fn transient_call_sites_include_function_scopes_under_command_substitution() {
    let source = "\
printf '%s\\n' \"$(
  caller() {
    target
  }
  caller
)\"
";
    let model = model(source);
    let analysis = model.analysis();
    let site = &model.call_sites_for(&Name::from("target"))[0];

    assert!(analysis.call_site_runs_in_transient_context(site.scope));
}

#[test]
fn function_scope_boundary_helpers_ignore_outer_transient_ancestors() {
    let source = "\
printf '%s\\n' \"$(
  caller() {
    printf '%s\\n' \"$1\"
  }
  caller
)\"
";
    let model = model(source);
    let scope = model.scope_at(
        span_for_nth(source, "printf '%s\\n' \"$1\"", 0)
            .start
            .offset(),
    );

    assert!(
        model
            .transient_ancestor_scopes_within_function(scope)
            .next()
            .is_none()
    );
    assert!(
        model
            .innermost_transient_scope_within_function(scope)
            .is_none()
    );
    assert_eq!(
        model.enclosing_function_scope_without_transient_boundary(scope),
        model.enclosing_function_scope(scope)
    );
}

#[test]
fn function_scope_boundary_helpers_stop_at_in_function_transient_scopes() {
    let source = "\
caller() {
  value=\"$(
    printf '%s\\n' \"$1\"
  )\"
}
";
    let model = model(source);
    let scope = model.scope_at(
        span_for_nth(source, "printf '%s\\n' \"$1\"", 0)
            .start
            .offset(),
    );
    let transients = model
        .transient_ancestor_scopes_within_function(scope)
        .collect::<Vec<_>>();

    assert_eq!(transients.len(), 1);
    assert!(matches!(
        model.scope_kind(transients[0]),
        ScopeKind::CommandSubstitution
    ));
    assert_eq!(
        model.innermost_transient_scope_within_function(scope),
        Some(transients[0])
    );
    assert_eq!(
        model.enclosing_function_scope_without_transient_boundary(scope),
        None
    );
}

#[test]
fn function_call_reachability_finds_direct_call_before_cutoff() {
    let source = "\
target() { :; }
target
exit
";
    let model = model(source);
    let binding = function_binding_id(&model, "target", 0);
    let cutoff = span_for_nth(source, "exit", 0).start.offset();
    let analysis = model.analysis();
    let mut reachability = analysis.direct_function_call_reachability(Vec::new());

    assert!(reachability.binding_has_reachable_direct_call(
        binding,
        DirectFunctionCallWindow::before_offset(cutoff)
    ));
}

#[test]
fn function_call_reachability_respects_between_offsets() {
    let source = "\
target() { :; }
echo before
target
exit
";
    let model = model(source);
    let binding = function_binding_id(&model, "target", 0);
    let after = span_for_nth(source, "echo", 0).start.offset();
    let before = span_for_nth(source, "exit", 0).start.offset();
    let early_before = span_for_nth(source, "target", 1).start.offset() - 1;
    let analysis = model.analysis();
    let mut reachability = analysis.direct_function_call_reachability(Vec::new());

    assert!(reachability.binding_has_reachable_direct_call(
        binding,
        DirectFunctionCallWindow::between_offsets(after, before)
    ));
    assert!(!reachability.binding_has_reachable_direct_call(
        binding,
        DirectFunctionCallWindow::between_offsets(after, early_before)
    ));
}

#[test]
fn function_call_reachability_persistent_query_excludes_transient_calls() {
    let source = "\
target() { :; }
value=$(target)
";
    let model = model(source);
    let binding = function_binding_id(&model, "target", 0);
    let analysis = model.analysis();
    let mut reachability = analysis.direct_function_call_reachability(Vec::new());

    assert!(reachability.binding_has_reachable_direct_call(
        binding,
        DirectFunctionCallWindow::before_offset(source.len())
    ));
    assert!(!reachability.binding_has_reachable_direct_call(
        binding,
        DirectFunctionCallWindow::before_offset(source.len()).persistent()
    ));
}

#[test]
fn function_call_reachability_follows_nested_function_activation() {
    let source = "\
target() { :; }
wrapper() {
  target
}
wrapper
";
    let model = model(source);
    let binding = function_binding_id(&model, "target", 0);
    let analysis = model.analysis();
    let mut reachability = analysis.direct_function_call_reachability(Vec::new());

    assert!(reachability.binding_has_reachable_direct_call(
        binding,
        DirectFunctionCallWindow::before_offset(source.len())
    ));
}

#[test]
fn function_call_reachability_blocks_prior_shadowed_binding() {
    let source = "\
target() { : old; }
target() { : new; }
target
";
    let model = model(source);
    let first = function_binding_id(&model, "target", 0);
    let second = function_binding_id(&model, "target", 1);
    let analysis = model.analysis();
    let mut reachability = analysis.direct_function_call_reachability(Vec::new());

    assert!(!reachability.binding_has_reachable_direct_call(
        first,
        DirectFunctionCallWindow::before_offset(source.len())
    ));
    assert!(reachability.binding_has_reachable_direct_call(
        second,
        DirectFunctionCallWindow::before_offset(source.len())
    ));
}

#[test]
fn function_call_reachability_blocks_prior_binding_called_from_later_wrapper() {
    let source = "\
target() { : old; }
wrapper() {
  target
}
target() { : new; }
wrapper
";
    let model = model(source);
    let first = function_binding_id(&model, "target", 0);
    let second = function_binding_id(&model, "target", 1);
    let analysis = model.analysis();
    let mut reachability = analysis.direct_function_call_reachability(Vec::new());

    assert!(!reachability.binding_has_reachable_direct_call(
        first,
        DirectFunctionCallWindow::before_offset(source.len())
    ));
    assert!(reachability.binding_has_reachable_direct_call(
        second,
        DirectFunctionCallWindow::before_offset(source.len())
    ));
}

#[test]
fn function_call_reachability_uses_supplemental_call_candidates() {
    let source = "\
target() { :; }
command target
";
    let model = model(source);
    let binding = function_binding_id(&model, "target", 0);
    let target_span = span_for_nth(source, "target", 1);
    let command_span = span_for_nth(source, "command target", 0);
    let supplemental = vec![FunctionCallCandidate {
        callee: Name::from("target"),
        scope: model.scope_at(target_span.start.offset()),
        name_span: target_span,
        command_span,
    }];

    let analysis = model.analysis();
    let mut without_supplemental = analysis.direct_function_call_reachability(Vec::new());
    assert!(!without_supplemental.binding_has_reachable_direct_call(
        binding,
        DirectFunctionCallWindow::before_offset(source.len())
    ));

    let mut with_supplemental = analysis.direct_function_call_reachability(supplemental);
    assert!(with_supplemental.binding_has_reachable_direct_call(
        binding,
        DirectFunctionCallWindow::before_offset(source.len())
    ));
}

#[test]
fn unreached_functions_count_transitive_nested_calls_before_enclosing_scope_exits() {
    let source = "\
outer() {
  inner() { :; }
  wrapper() {
    inner
  }
  wrapper
}
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert!(unreached.is_empty());
}

#[test]
fn unreached_functions_follow_nested_direct_call_chains() {
    let source = "\
outer() {
  wrapper() {
    inner() { :; }
  }
  wrapper
}
outer
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert!(unreached.is_empty());
}

#[test]
fn unreached_functions_count_nested_definitions_installed_before_later_body_call() {
    let source = "\
provider() {
  helper() { :; }
}
consumer() {
  provider
  helper
}
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert!(unreached.is_empty());
}

#[test]
fn unreached_functions_report_nested_definitions_when_provider_call_follows_use() {
    let source = "\
provider() {
  helper() { :; }
}
consumer() {
  helper
  provider
}
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "helper");
}

#[test]
fn unreached_functions_do_not_count_provider_call_when_consumer_runs_before_definition() {
    let source = "\
consumer() {
  provider
  helper
}
consumer
provider() {
  helper() { :; }
}
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "helper");
}

#[test]
fn unreached_functions_ignore_unreachable_consumer_calls_before_provider_definition() {
    let source = "\
consumer() {
  provider
  helper
}
guard() {
  return
  consumer
}
guard
provider() {
  helper() { :; }
}
consumer
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert!(unreached.is_empty());
}

#[test]
fn unreached_functions_report_nested_definitions_when_intermediate_scope_is_not_called() {
    let source = "\
outer() {
  wrapper() {
    inner() { :; }
  }
}
outer
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "inner");
}

#[test]
fn unreached_functions_do_not_count_calls_before_enclosing_definition() {
    let source = "\
outer
outer() {
  inner() { :; }
}
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions_with_options(UnreachedFunctionAnalysisOptions {
        report_unreached_nested_definitions: true,
    });

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "inner");
}

#[test]
fn unreached_functions_ignore_direct_calls_before_termination() {
    let source = "f() { echo hi; }\nf\nexit 0\n";
    let model = model(source);

    assert!(model.analysis().unreached_functions().is_empty());
}

#[test]
fn unreached_functions_count_direct_calls_that_terminate() {
    let source = "f() { exit 1; }\nf\n";
    let model = model(source);

    assert!(model.analysis().unreached_functions().is_empty());
}

#[test]
fn unreached_functions_report_unreachable_definitions() {
    let source = "exit 0\nf() { echo hi; }\n";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions();

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "f");
    assert_eq!(
        unreached[0].reason,
        UnreachedFunctionReason::UnreachableDefinition
    );
}

#[test]
fn unreached_functions_ignore_non_guaranteed_termination() {
    let source = "f() { echo hi; }\nif cond; then exit 0; fi\n";
    let model = model(source);

    assert!(model.analysis().unreached_functions().is_empty());
}

#[test]
fn unreached_functions_count_transitive_direct_calls_before_termination() {
    let source = "\
run_case() {
  helper
}
helper() { echo hi; }
run_case
exit 0
";
    let model = model(source);

    assert!(model.analysis().unreached_functions().is_empty());
}

#[test]
fn unreached_functions_ignore_body_calls_when_enclosing_function_runs_before_definition() {
    let source = "\
helper() { :; }
main() {
  late
}
main
late() {
  helper
}
exit 0
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions();
    let names = unreached
        .iter()
        .map(|function| function.name.as_str())
        .collect::<Vec<_>>();

    assert_eq!(names, ["helper", "late"]);
}

#[test]
fn unreached_functions_count_nested_substitution_calls_before_termination() {
    let source = "\
run_case() {
  output=\"$(echo value | helper)\"
}
helper() { echo hi; }
run_case
exit 0
";
    let model = model(source);

    assert!(model.analysis().unreached_functions().is_empty());
}

#[test]
fn unreached_functions_ignore_shadowed_command_substitution_calls_before_termination() {
    let source = "\
helper() { echo hi; }
output=$(helper() { :; }; helper)
exit 0
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions();

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "helper");
    assert_eq!(
        unreached[0].reason,
        UnreachedFunctionReason::ScriptTerminates
    );
}

#[test]
fn unreached_functions_ignore_shadowed_test_argument_command_substitution_calls() {
    let source = "\
helper() { echo hi; }
if [ \"$(helper() { :; }; helper)\" = hi ]; then
  echo yes
fi
exit 0
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions();

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "helper");
    assert_eq!(
        unreached[0].reason,
        UnreachedFunctionReason::ScriptTerminates
    );
}

#[test]
fn unreached_functions_ignore_command_substitution_paths_before_later_termination() {
    let source = "\
f() { echo hi; }
output=$(echo value)
exit 0
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions();

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "f");
    assert_eq!(
        unreached[0].reason,
        UnreachedFunctionReason::ScriptTerminates
    );
}

#[test]
fn unreached_functions_ignore_pipeline_paths_before_later_termination() {
    let source = "\
f() { echo hi; }
echo value | cat
exit 0
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached = analysis.unreached_functions();

    assert_eq!(unreached.len(), 1);
    assert_eq!(unreached[0].name, "f");
    assert_eq!(
        unreached[0].reason,
        UnreachedFunctionReason::ScriptTerminates
    );
}

#[test]
fn unreached_functions_count_negated_condition_calls_before_termination() {
    let source = "\
die() { exit 1; }
check() { :; }
validate() {
  if ! check \"$value\"; then
    die
  fi
}
validate
exit 0
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached_names = analysis
        .unreached_functions()
        .iter()
        .map(|unreached| unreached.name.as_str())
        .collect::<Vec<_>>();

    assert!(!unreached_names.contains(&"check"));
}

#[test]
fn unreached_functions_count_calls_to_branch_selected_definitions() {
    let source = "\
if use_first; then
  helper() { echo first; }
else
  helper() { echo second; }
fi
run_case() {
  helper
}
run_case
exit 0
";
    let model = model(source);

    assert!(model.analysis().unreached_functions().is_empty());
}

#[test]
fn unreached_functions_count_late_bound_wrapper_calls_newer_definition() {
    let source = "\
helper() { echo old; }
run_case() {
  helper
}
helper() { echo new; }
run_case
exit 0
";
    let model = model(source);
    let analysis = model.analysis();
    let unreached_names = analysis
        .unreached_functions()
        .iter()
        .map(|unreached| unreached.name.as_str())
        .collect::<Vec<_>>();

    assert!(!unreached_names.contains(&"helper"));
}

#[test]
fn tracks_flow_context_for_conditions_and_loops() {
    let source = "\
if cmd; then
  echo ok
fi
for x in 1 2; do
  break
done
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build(&output.file, source, &indexer);

    let Command::Compound(CompoundCommand::If(if_command)) = &output.file.body[0].command else {
        panic!("expected if command");
    };
    let condition_span = match &if_command.condition[0].command {
        Command::Simple(command) => command.span,
        other => panic!("unexpected condition command: {other:?}"),
    };
    let condition_context = model.flow_context_at(&condition_span).unwrap();
    assert!(condition_context.exit_status_checked);

    let Command::Compound(CompoundCommand::For(for_command)) = &output.file.body[1].command else {
        panic!("expected for command");
    };
    let break_span = match &for_command.body[0].command {
        Command::Builtin(shuck_ast::BuiltinCommand::Break(command)) => command.span,
        other => panic!("unexpected loop body command: {other:?}"),
    };
    let break_context = model.flow_context_at(&break_span).unwrap();
    assert_eq!(break_context.loop_depth, 1);
}

#[test]
fn brace_group_effect_scan_preserves_sibling_flow_context() {
    let source = "\
{ emulate sh; }
echo ok
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build(&output.file, source, &indexer);

    let Command::Compound(CompoundCommand::BraceGroup(commands)) = &output.file.body[0].command
    else {
        panic!("expected brace group");
    };
    let Command::Simple(inner_command) = &commands[0].command else {
        panic!("expected inner simple command");
    };
    let inner_context = model.flow_context_at(&inner_command.span).unwrap();
    assert!(inner_context.in_block);

    let Command::Simple(sibling_command) = &output.file.body[1].command else {
        panic!("expected sibling simple command");
    };
    let sibling_context = model.flow_context_at(&sibling_command.span).unwrap();
    assert!(!sibling_context.in_block);
}

#[test]
fn command_contexts_track_nested_words_and_condition_roles() {
    let source = "\
echo $(if probe; then inner; fi)
cat < <(process)
if outer; then ok; elif alt; then fallback; fi
while guard; do break; done
until stop; do done_cmd; done
while if nested_guard; then :; elif nested_alt; then :; fi; do :; done
cat <<EOF
$(heredoc_cmd)
EOF
";
    let model = model(source);

    let echo = model
        .command_context(command_id_starting_with(&model, source, "echo").unwrap())
        .unwrap();
    assert!(!echo.is_nested_word_command());
    assert_eq!(echo.nested_word_command_depth(), 0);
    assert!(echo.is_structural());
    assert_eq!(echo.condition_role(), None);

    let nested_if = model
        .command_context(command_id_starting_with(&model, source, "if probe").unwrap())
        .unwrap();
    assert!(nested_if.is_nested_word_command());
    assert_eq!(nested_if.nested_word_command_depth(), 1);
    assert_eq!(nested_if.condition_role(), None);

    let probe = model
        .command_context(command_id_starting_with(&model, source, "probe").unwrap())
        .unwrap();
    assert!(probe.is_nested_word_command());
    assert_eq!(probe.nested_word_command_depth(), 1);
    assert!(probe.is_in_if_condition());
    assert!(!probe.is_in_elif_condition());
    assert_eq!(probe.condition_role(), Some(CommandConditionRole::If));

    let process = model
        .command_context(command_id_starting_with(&model, source, "process").unwrap())
        .unwrap();
    assert!(process.is_nested_word_command());
    assert_eq!(process.nested_word_command_depth(), 1);

    let outer = model
        .command_context(command_id_starting_with(&model, source, "outer").unwrap())
        .unwrap();
    assert!(!outer.is_nested_word_command());
    assert_eq!(outer.nested_word_command_depth(), 0);
    assert!(outer.is_in_if_condition());
    assert_eq!(outer.condition_role(), Some(CommandConditionRole::If));

    let alt = model
        .command_context(command_id_starting_with(&model, source, "alt").unwrap())
        .unwrap();
    assert!(alt.is_in_if_condition());
    assert!(alt.is_in_elif_condition());
    assert_eq!(alt.condition_role(), Some(CommandConditionRole::Elif));

    let guard = model
        .command_context(command_id_starting_with(&model, source, "guard").unwrap())
        .unwrap();
    assert_eq!(guard.condition_role(), Some(CommandConditionRole::While));

    let stop = model
        .command_context(command_id_starting_with(&model, source, "stop").unwrap())
        .unwrap();
    assert_eq!(stop.condition_role(), Some(CommandConditionRole::Until));

    let nested_guard = model
        .command_context(command_id_starting_with(&model, source, "nested_guard").unwrap())
        .unwrap();
    assert_eq!(
        nested_guard.condition_role(),
        Some(CommandConditionRole::If)
    );

    let nested_alt = model
        .command_context(command_id_starting_with(&model, source, "nested_alt").unwrap())
        .unwrap();
    assert_eq!(
        nested_alt.condition_role(),
        Some(CommandConditionRole::Elif)
    );

    let heredoc = model
        .command_context(command_id_starting_with(&model, source, "heredoc_cmd").unwrap())
        .unwrap();
    assert!(heredoc.is_nested_word_command());
    assert_eq!(heredoc.nested_word_command_depth(), 1);
}

#[test]
fn condition_contexts_do_not_flow_into_function_bodies() {
    let source = "if helper() { echo body; }; then :; fi\n";
    let model = model(source);

    let helper = model
        .command_context(command_id_starting_with(&model, source, "helper").unwrap())
        .unwrap();
    assert!(helper.is_in_if_condition());
    assert_eq!(helper.condition_role(), Some(CommandConditionRole::If));

    let body = model
        .command_context(command_id_starting_with(&model, source, "echo body").unwrap())
        .unwrap();
    assert!(!body.is_in_if_condition());
    assert!(!body.is_in_elif_condition());
    assert_eq!(body.condition_role(), None);
}

#[test]
fn previous_visible_binding_can_ignore_the_current_assignment_span() {
    let model = model(
        "\
#!/bin/bash
value=outer
f() {
  local value=inner
  value=next
}
",
    );
    let binding_ids = model.bindings_for(&Name::from("value"));
    let local = model.binding(binding_ids[1]);
    let current = model.binding(binding_ids[2]);

    assert_eq!(
        model
            .visible_binding(&Name::from("value"), current.span)
            .map(|binding| binding.id),
        Some(current.id)
    );
    assert_eq!(
        model
            .previous_visible_binding(&Name::from("value"), current.span, Some(current.span))
            .map(|binding| binding.id),
        Some(local.id)
    );
}

#[test]
fn visible_candidate_bindings_for_reference_returns_visible_scope_chain() {
    let source = "\
#!/bin/bash
value=outer
f() {
  local value=inner
  printf '%s\\n' \"$value\"
}
";
    let model = model(source);
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "value")
        .unwrap();
    let candidates = model.visible_candidate_bindings_for_reference(reference);
    let value_bindings = model.bindings_for(&Name::from("value"));

    assert_eq!(candidates, vec![value_bindings[1], value_bindings[0]]);
}

#[test]
fn visible_candidate_bindings_for_reference_falls_back_to_prior_outer_scope_bindings() {
    let source = "\
#!/bin/bash
first() {
  target=(one two)
}
second() {
  printf '%s\\n' \"$target\"
}
";
    let model = model(source);
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "target")
        .unwrap();
    let candidates = model.visible_candidate_bindings_for_reference(reference);

    assert_eq!(candidates, model.bindings_for(&Name::from("target")));
}

#[test]
fn assoc_lookup_binding_prefers_visible_assoc_declarations_and_respects_local_shadowing() {
    let model = model(
        "\
#!/bin/bash
declare -A map
helper() {
  map[$key]=1
}
shadow() {
  local map
  map[$key]=2
}
",
    );
    let helper_scope = model
        .scopes()
        .iter()
        .find_map(|scope| match &scope.kind {
            ScopeKind::Function(FunctionScopeKind::Named(names))
                if names.iter().any(|name| name == "helper") =>
            {
                Some(scope.id)
            }
            _ => None,
        })
        .expect("expected helper scope");
    let shadow_scope = model
        .scopes()
        .iter()
        .find_map(|scope| match &scope.kind {
            ScopeKind::Function(FunctionScopeKind::Named(names))
                if names.iter().any(|name| name == "shadow") =>
            {
                Some(scope.id)
            }
            _ => None,
        })
        .expect("expected shadow scope");
    let helper_assignment = model
        .bindings_for(&Name::from("map"))
        .iter()
        .copied()
        .find(|binding_id| {
            let binding = model.binding(*binding_id);
            binding.scope == helper_scope && binding.kind == BindingKind::ArrayAssignment
        })
        .expect("expected helper assignment binding");
    let shadow_local = model
        .bindings_for(&Name::from("map"))
        .iter()
        .copied()
        .find(|binding_id| {
            let binding = model.binding(*binding_id);
            binding.scope == shadow_scope
                && matches!(binding.kind, BindingKind::Declaration(_))
                && binding.attributes.contains(BindingAttributes::LOCAL)
        })
        .expect("expected shadowing local binding");
    let shadow_assignment = model
        .bindings_for(&Name::from("map"))
        .iter()
        .copied()
        .find(|binding_id| {
            let binding = model.binding(*binding_id);
            binding.scope == shadow_scope && binding.kind == BindingKind::ArrayAssignment
        })
        .expect("expected shadow assignment binding");

    assert!(
        model
            .visible_binding_for_assoc_lookup(
                &Name::from("map"),
                helper_scope,
                model.binding(helper_assignment).span,
            )
            .is_some_and(|binding| binding.attributes.contains(BindingAttributes::ASSOC))
    );
    assert_eq!(
        model
            .visible_binding_for_assoc_lookup(
                &Name::from("map"),
                shadow_scope,
                model.binding(shadow_assignment).span,
            )
            .map(|binding| binding.id),
        Some(shadow_local)
    );
}

#[test]
fn imported_entry_bindings_insert_in_visibility_order() {
    let source = "\
#!/bin/bash
value=local
echo $value
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build_with_options(
        &output.file,
        source,
        &indexer,
        SemanticBuildOptions {
            file_entry_contract: Some(FileContract {
                required_reads: Vec::new(),
                provided_bindings: vec![ProvidedBinding::new(
                    Name::from("value"),
                    ProvidedBindingKind::Variable,
                    ContractCertainty::Definite,
                )],
                provided_functions: Vec::new(),
                externally_consumed_bindings: false,
                externally_consumed_binding_names: Vec::new(),
                externally_consumed_binding_prefixes: Vec::new(),
            }),
            ..SemanticBuildOptions::default()
        },
    );
    let value_bindings = model.bindings_for(&Name::from("value"));

    assert_eq!(value_bindings.len(), 2);
    assert!(matches!(
        model.binding(value_bindings[0]).kind,
        BindingKind::Imported
    ));
    assert!(matches!(
        model.binding(value_bindings[1]).kind,
        BindingKind::Assignment
    ));

    let value_reference = model
        .references()
        .iter()
        .find(|reference| {
            reference.name.as_str() == "value" && reference.span.slice(source) == "$value"
        })
        .expect("expected value reference");

    assert!(matches!(
        model.visible_binding(&Name::from("value"), value_reference.span),
        Some(binding) if binding.kind == BindingKind::Assignment
    ));
}

#[test]
fn name_only_export_consumes_existing_binding() {
    let source = "foo=1\nexport foo\n";
    let model = model(source);

    let foo_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo")
        .collect::<Vec<_>>();
    assert_eq!(foo_bindings.len(), 1);
    assert!(
        foo_bindings[0]
            .attributes
            .contains(BindingAttributes::EXPORTED)
    );

    let declaration_reference = model
        .references()
        .iter()
        .find(|reference| {
            reference.kind == ReferenceKind::DeclarationName && reference.name == "foo"
        })
        .unwrap();
    let resolved = model.resolved_binding(declaration_reference.id).unwrap();
    assert_eq!(resolved.id, foo_bindings[0].id);
}

#[test]
fn name_only_local_creates_a_binding_for_later_reads() {
    let source = "f() { local VAR; echo \"$VAR\"; }\n";
    let model = model(source);

    let local_binding = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "VAR"
                && matches!(
                    binding.kind,
                    BindingKind::Declaration(DeclarationBuiltin::Local)
                )
        })
        .unwrap();
    assert!(
        !local_binding
            .attributes
            .contains(BindingAttributes::DECLARATION_INITIALIZED)
    );

    let reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "VAR")
        .unwrap();
    let resolved = model.resolved_binding(reference.id).unwrap();
    assert_eq!(resolved.id, local_binding.id);
    let reference_id = reference.id;
    let analysis = model.analysis();
    let uninitialized = analysis.uninitialized_references();
    assert_eq!(uninitialized.len(), 1);
    assert_eq!(uninitialized[0].reference, reference_id);
    assert_eq!(uninitialized[0].certainty, UninitializedCertainty::Definite);
}

#[test]
fn declaration_function_name_operands_do_not_create_variable_bindings() {
    let source = "\
declare -f -F config_unset >/dev/null
export -f helper
readonly -f locked
typeset -f typed
";
    let model = model(source);
    let function_operand_names = ["config_unset", "helper", "locked", "typed"];

    for name in function_operand_names {
        assert!(
            model.bindings().iter().all(|binding| binding.name != name),
            "{name} should be treated as a function name, not a variable binding"
        );
        assert!(
            model.references().iter().all(|reference| {
                reference.name != name || reference.kind != ReferenceKind::DeclarationName
            }),
            "{name} should not create a declaration-name variable reference"
        );
    }
}

#[test]
fn declaration_plus_function_flags_keep_name_operands_as_variables() {
    let source = "\
declare +f config_unset
declare +F config_maybe
declare -f +f config_after_toggle
declare +f config_before -f helper_function
declare -f hidden_function +f config_after
typeset +f typed
";
    let model = model(source);
    let variable_operand_names = [
        "config_unset",
        "config_maybe",
        "config_after_toggle",
        "config_before",
        "config_after",
        "typed",
    ];

    for name in variable_operand_names {
        assert!(
            model.bindings().iter().any(|binding| binding.name == name
                && matches!(binding.kind, BindingKind::Declaration(_))),
            "{name} should be treated as a variable declaration"
        );
    }

    let function_operand_names = ["helper_function", "hidden_function"];
    for name in function_operand_names {
        assert!(
            model.bindings().iter().all(|binding| binding.name != name),
            "{name} should be treated as a function name, not a variable binding"
        );
        assert!(
            model.references().iter().all(|reference| {
                reference.name != name || reference.kind != ReferenceKind::DeclarationName
            }),
            "{name} should not create a declaration-name variable reference"
        );
    }
}

#[test]
fn repeated_name_only_local_reuses_existing_same_scope_binding() {
    let source = "f() { local foo=1; local foo; echo \"$foo\"; }\n";
    let model = model(source);

    let foo_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo")
        .collect::<Vec<_>>();
    assert_eq!(foo_bindings.len(), 1);
    assert!(
        foo_bindings[0]
            .attributes
            .contains(BindingAttributes::LOCAL)
    );
    assert!(
        foo_bindings[0]
            .attributes
            .contains(BindingAttributes::DECLARATION_INITIALIZED)
    );

    let declaration_reference = model
        .references()
        .iter()
        .find(|reference| {
            reference.kind == ReferenceKind::DeclarationName && reference.name == "foo"
        })
        .unwrap();
    let resolved_declaration = model.resolved_binding(declaration_reference.id).unwrap();
    assert_eq!(resolved_declaration.id, foo_bindings[0].id);

    let expansion_reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
        .unwrap();
    let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
    assert_eq!(resolved_expansion.id, foo_bindings[0].id);

    let analysis = model.analysis();
    assert!(analysis.uninitialized_references().is_empty());
    assert!(analysis.unused_assignments().is_empty());
}

#[test]
fn name_only_local_after_local_write_reuses_existing_local_state() {
    let source = "f() { local foo=1; foo=2; local foo; echo \"$foo\"; }\n";
    let model = model(source);

    let foo_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo")
        .collect::<Vec<_>>();
    assert_eq!(foo_bindings.len(), 2);
    assert!(
        foo_bindings[0]
            .attributes
            .contains(BindingAttributes::LOCAL)
    );
    assert!(
        foo_bindings[1]
            .attributes
            .contains(BindingAttributes::LOCAL)
    );

    let declaration_reference = model
        .references()
        .iter()
        .find(|reference| {
            reference.kind == ReferenceKind::DeclarationName && reference.name == "foo"
        })
        .unwrap();
    let resolved_declaration = model.resolved_binding(declaration_reference.id).unwrap();
    assert_eq!(resolved_declaration.id, foo_bindings[1].id);

    let expansion_reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
        .unwrap();
    let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
    assert_eq!(resolved_expansion.id, foo_bindings[1].id);

    let unused_binding_ids = model.analysis().dataflow().unused_assignment_ids().to_vec();
    assert_eq!(unused_binding_ids, vec![foo_bindings[0].id]);
    assert!(model.analysis().uninitialized_references().is_empty());
}

#[test]
fn name_only_local_after_conditional_unset_reuses_existing_local_state() {
    let source = "\
f() {
  local foo=1
  if cond; then
    unset foo
  fi
  local foo
  echo \"$foo\"
}
";
    let model = model(source);
    let function_scope = model
        .scopes()
        .iter()
        .find_map(|scope| match &scope.kind {
            ScopeKind::Function(FunctionScopeKind::Named(names))
                if names.iter().any(|name| name == "f") =>
            {
                Some(scope.id)
            }
            _ => None,
        })
        .expect("expected function scope");

    let foo_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo" && binding.scope == function_scope)
        .collect::<Vec<_>>();
    assert_eq!(foo_bindings.len(), 1);
    assert!(
        foo_bindings[0]
            .attributes
            .contains(BindingAttributes::LOCAL)
    );

    let expansion_reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
        .expect("expected foo expansion");
    let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
    assert_eq!(resolved_expansion.id, foo_bindings[0].id);

    let analysis = model.analysis();
    assert!(analysis.uninitialized_references().is_empty());
    assert!(analysis.unused_assignments().is_empty());
}

#[test]
fn name_only_local_after_invalid_unset_flag_reuses_existing_local_state() {
    let source = "\
f() {
  local foo=1
  unset -z foo
  local foo
  echo \"$foo\"
}
";
    let model = model(source);
    let function_scope = model
        .scopes()
        .iter()
        .find_map(|scope| match &scope.kind {
            ScopeKind::Function(FunctionScopeKind::Named(names))
                if names.iter().any(|name| name == "f") =>
            {
                Some(scope.id)
            }
            _ => None,
        })
        .expect("expected function scope");

    let foo_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo" && binding.scope == function_scope)
        .collect::<Vec<_>>();
    assert_eq!(foo_bindings.len(), 1);
    assert!(
        foo_bindings[0]
            .attributes
            .contains(BindingAttributes::LOCAL)
    );

    let expansion_reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
        .expect("expected foo expansion");
    let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
    assert_eq!(resolved_expansion.id, foo_bindings[0].id);

    let analysis = model.analysis();
    assert!(analysis.uninitialized_references().is_empty());
    assert!(analysis.unused_assignments().is_empty());
}

#[test]
fn name_only_local_after_conflicting_unset_flags_reuses_existing_local_state() {
    let source = "\
f() {
  local foo=1
  unset -vf foo
  local foo
  echo \"$foo\"
}
";
    let model = model(source);
    let function_scope = model
        .scopes()
        .iter()
        .find_map(|scope| match &scope.kind {
            ScopeKind::Function(FunctionScopeKind::Named(names))
                if names.iter().any(|name| name == "f") =>
            {
                Some(scope.id)
            }
            _ => None,
        })
        .expect("expected function scope");

    let foo_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo" && binding.scope == function_scope)
        .collect::<Vec<_>>();
    assert_eq!(foo_bindings.len(), 1);
    assert!(
        foo_bindings[0]
            .attributes
            .contains(BindingAttributes::LOCAL)
    );

    let expansion_reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
        .expect("expected foo expansion");
    let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
    assert_eq!(resolved_expansion.id, foo_bindings[0].id);

    let analysis = model.analysis();
    assert!(analysis.uninitialized_references().is_empty());
    assert!(analysis.unused_assignments().is_empty());
}

#[test]
fn name_only_local_after_split_conflicting_unset_flags_reuses_existing_local_state() {
    let source = "\
f() {
  local foo=1
  unset -f -v foo
  local foo
  echo \"$foo\"
}
";
    let model = model(source);
    let function_scope = model
        .scopes()
        .iter()
        .find_map(|scope| match &scope.kind {
            ScopeKind::Function(FunctionScopeKind::Named(names))
                if names.iter().any(|name| name == "f") =>
            {
                Some(scope.id)
            }
            _ => None,
        })
        .expect("expected function scope");

    let foo_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo" && binding.scope == function_scope)
        .collect::<Vec<_>>();
    assert_eq!(foo_bindings.len(), 1);
    assert!(
        foo_bindings[0]
            .attributes
            .contains(BindingAttributes::LOCAL)
    );

    let expansion_reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
        .expect("expected foo expansion");
    let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
    assert_eq!(resolved_expansion.id, foo_bindings[0].id);

    let analysis = model.analysis();
    assert!(analysis.uninitialized_references().is_empty());
    assert!(analysis.unused_assignments().is_empty());
}

#[test]
fn unset_n_clears_nameref_binding_state_before_name_only_local() {
    let source = "\
f() {
  local foo=1
  local -n ref=foo
  unset -n ref
  local ref
}
";
    let model = model(source);
    let function_scope = model
        .scopes()
        .iter()
        .find_map(|scope| match &scope.kind {
            ScopeKind::Function(FunctionScopeKind::Named(names))
                if names.iter().any(|name| name == "f") =>
            {
                Some(scope.id)
            }
            _ => None,
        })
        .expect("expected function scope");

    let ref_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "ref" && binding.scope == function_scope)
        .collect::<Vec<_>>();

    assert!(
        ref_bindings
            .iter()
            .any(|binding| matches!(binding.kind, BindingKind::Nameref))
    );
    let redeclared_local = ref_bindings
        .iter()
        .find(|binding| {
            matches!(
                binding.kind,
                BindingKind::Declaration(DeclarationBuiltin::Local)
            )
        })
        .expect("expected fresh local binding after unset -n");
    assert!(
        !redeclared_local
            .attributes
            .contains(BindingAttributes::NAMEREF)
    );
}

#[test]
fn name_only_local_after_unset_n_plain_variable_reuses_existing_local_state() {
    let source = "\
f() {
  local foo=1
  unset -n foo
  local foo
  echo \"$foo\"
}
";
    let model = model(source);
    let function_scope = model
        .scopes()
        .iter()
        .find_map(|scope| match &scope.kind {
            ScopeKind::Function(FunctionScopeKind::Named(names))
                if names.iter().any(|name| name == "f") =>
            {
                Some(scope.id)
            }
            _ => None,
        })
        .expect("expected function scope");

    let foo_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo" && binding.scope == function_scope)
        .collect::<Vec<_>>();
    assert_eq!(foo_bindings.len(), 1);
    assert!(
        foo_bindings[0]
            .attributes
            .contains(BindingAttributes::LOCAL)
    );

    let expansion_reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
        .expect("expected foo expansion");
    let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
    assert_eq!(resolved_expansion.id, foo_bindings[0].id);

    let analysis = model.analysis();
    assert!(analysis.uninitialized_references().is_empty());
    assert!(analysis.unused_assignments().is_empty());
}

#[test]
fn name_only_local_after_dynamic_unset_option_word_reuses_existing_local_state() {
    let source = "\
f() {
  local foo=1
  local mode=-f
  unset \"$mode\" foo
  local foo
  echo \"$foo\"
}
";
    let model = model(source);
    let function_scope = model
        .scopes()
        .iter()
        .find_map(|scope| match &scope.kind {
            ScopeKind::Function(FunctionScopeKind::Named(names))
                if names.iter().any(|name| name == "f") =>
            {
                Some(scope.id)
            }
            _ => None,
        })
        .expect("expected function scope");

    let foo_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo" && binding.scope == function_scope)
        .collect::<Vec<_>>();
    assert_eq!(foo_bindings.len(), 1);
    assert!(
        foo_bindings[0]
            .attributes
            .contains(BindingAttributes::LOCAL)
    );

    let expansion_reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "foo")
        .expect("expected foo expansion");
    let resolved_expansion = model.resolved_binding(expansion_reference.id).unwrap();
    assert_eq!(resolved_expansion.id, foo_bindings[0].id);

    let analysis = model.analysis();
    assert!(analysis.uninitialized_references().is_empty());
    assert!(analysis.unused_assignments().is_empty());
}

#[test]
fn name_only_local_after_unset_creates_fresh_non_assoc_binding() {
    let source = "\
main() {
  local key=name
  declare -A map
  unset map
  local map
  map[$key]=1
}
";
    let model = model(source);
    let main_scope = model
        .scopes()
        .iter()
        .find_map(|scope| match &scope.kind {
            ScopeKind::Function(FunctionScopeKind::Named(names))
                if names.iter().any(|name| name == "main") =>
            {
                Some(scope.id)
            }
            _ => None,
        })
        .expect("expected main scope");
    let redeclared_local = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "map"
                && binding.scope == main_scope
                && matches!(
                    binding.kind,
                    BindingKind::Declaration(DeclarationBuiltin::Local)
                )
        })
        .expect("expected redeclared local binding");
    let array_assignment = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "map"
                && binding.scope == main_scope
                && binding.kind == BindingKind::ArrayAssignment
        })
        .expect("expected array assignment binding");

    assert!(
        !redeclared_local
            .attributes
            .contains(BindingAttributes::ASSOC)
    );
    assert_eq!(
            model
                .visible_binding_for_assoc_lookup(
                    &Name::from("map"),
                    main_scope,
                    array_assignment.span,
                )
                .map(|binding| binding.id),
            Some(redeclared_local.id)
        );
}

#[test]
fn special_command_targets_store_name_only_spans() {
    let source = "\
read -r read_target
read -ra read_array_target read_array_remainder
read -aattached_target
read -ar
mapfile mapfile_target
readarray readarray_target
printf -v printf_target '%s' value
printf -vattached_printf_target '%s' value
printf -- -vnot_a_printf_target
printf '%s' -vformat_text
getopts 'ab' getopts_target
";
    let model = model(source);

    let read_target = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "read_target" && matches!(binding.kind, BindingKind::ReadTarget)
        })
        .unwrap();
    assert_eq!(read_target.span.slice(source), "read_target");
    assert!(!read_target.attributes.contains(BindingAttributes::ARRAY));

    let read_array_target = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "read_array_target" && matches!(binding.kind, BindingKind::ReadTarget)
        })
        .unwrap();
    assert_eq!(read_array_target.span.slice(source), "read_array_target");
    assert!(
        read_array_target
            .attributes
            .contains(BindingAttributes::ARRAY)
    );
    assert!(!model.bindings().iter().any(|binding| {
        binding.name == "read_array_remainder" && matches!(binding.kind, BindingKind::ReadTarget)
    }));

    let attached_read_target = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "attached_target" && matches!(binding.kind, BindingKind::ReadTarget)
        })
        .unwrap();
    assert_eq!(attached_read_target.span.slice(source), "attached_target");

    let short_attached_read_target = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "r" && matches!(binding.kind, BindingKind::ReadTarget))
        .unwrap();
    assert_eq!(short_attached_read_target.span.slice(source), "r");

    let mapfile_target = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "mapfile_target" && matches!(binding.kind, BindingKind::MapfileTarget)
        })
        .unwrap();
    assert_eq!(mapfile_target.span.slice(source), "mapfile_target");
    assert!(mapfile_target.attributes.contains(BindingAttributes::ARRAY));

    let readarray_target = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "readarray_target" && matches!(binding.kind, BindingKind::MapfileTarget)
        })
        .unwrap();
    assert_eq!(readarray_target.span.slice(source), "readarray_target");
    assert!(
        readarray_target
            .attributes
            .contains(BindingAttributes::ARRAY)
    );

    let printf_target = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "printf_target" && matches!(binding.kind, BindingKind::PrintfTarget)
        })
        .unwrap();
    assert_eq!(printf_target.span.slice(source), "printf_target");

    let attached_printf_target = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "attached_printf_target"
                && matches!(binding.kind, BindingKind::PrintfTarget)
        })
        .unwrap();
    assert_eq!(
        attached_printf_target.span.slice(source),
        "attached_printf_target"
    );
    assert!(!model.bindings().iter().any(|binding| {
        matches!(binding.kind, BindingKind::PrintfTarget)
            && matches!(binding.name.as_str(), "not_a_printf_target" | "format_text")
    }));

    let getopts_target = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "getopts_target" && matches!(binding.kind, BindingKind::GetoptsTarget)
        })
        .unwrap();
    assert_eq!(getopts_target.span.slice(source), "getopts_target");
}

#[test]
fn special_command_target_parsing_skips_option_operands_and_tracks_implicit_mapfile() {
    let source = "\
delimiter=:
callback=cb
read -d delimiter -a read_array_target read_array_remainder <<<\":\"
mapfile -C callback -c 1 mapfile_target < <(printf '%s\\n' value)
mapfile
";
    let model = model(source);

    let read_targets = model
        .bindings()
        .iter()
        .filter(|binding| matches!(binding.kind, BindingKind::ReadTarget))
        .collect::<Vec<_>>();
    assert!(
        read_targets
            .iter()
            .any(|binding| binding.name == "read_array_target")
    );
    assert!(
        !read_targets
            .iter()
            .any(|binding| binding.name == "read_array_remainder")
    );
    assert!(
        !read_targets
            .iter()
            .any(|binding| binding.name == "delimiter")
    );

    let mapfile_targets = model
        .bindings()
        .iter()
        .filter(|binding| matches!(binding.kind, BindingKind::MapfileTarget))
        .collect::<Vec<_>>();
    assert!(
        mapfile_targets
            .iter()
            .any(|binding| binding.name == "mapfile_target")
    );
    assert!(mapfile_targets.iter().any(|binding| {
        binding.name == "MAPFILE"
            && binding.attributes.contains(BindingAttributes::ARRAY)
            && matches!(
                binding.origin,
                BindingOrigin::BuiltinTarget {
                    definition_span,
                    ..
                } if definition_span == binding.span
            )
    }));
    assert!(
        !mapfile_targets
            .iter()
            .any(|binding| binding.name == "callback")
    );
}

#[test]
fn zparseopts_targets_record_array_bindings_in_zsh() {
    let source = "\
zparseopts -D -E -F -a all -A assoc -- \\
  h=help -help=help \\
  v+:=verbose -verbose+:=verbose
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let target = |name: &str| {
        model
            .bindings()
            .iter()
            .find(|binding| {
                binding.name == name && matches!(binding.kind, BindingKind::ZparseoptsTarget)
            })
            .unwrap_or_else(|| panic!("missing zparseopts target {name}"))
    };

    let all = target("all");
    assert_eq!(all.span.slice(source), "all");
    assert!(all.attributes.contains(BindingAttributes::ARRAY));
    assert!(!all.attributes.contains(BindingAttributes::ASSOC));
    assert!(matches!(
        all.origin,
        BindingOrigin::BuiltinTarget {
            kind: BuiltinBindingTargetKind::Zparseopts,
            ..
        }
    ));

    let assoc = target("assoc");
    assert_eq!(assoc.span.slice(source), "assoc");
    assert!(assoc.attributes.contains(BindingAttributes::ARRAY));
    assert!(assoc.attributes.contains(BindingAttributes::ASSOC));

    for name in ["help", "verbose"] {
        let binding = target(name);
        assert_eq!(binding.span.slice(source), name);
        assert!(binding.attributes.contains(BindingAttributes::ARRAY));
        assert!(!binding.attributes.contains(BindingAttributes::ASSOC));
    }
}

#[test]
fn compinit_initializes_completion_tables_in_zsh() {
    let source = "\
#!/bin/zsh
autoload -Uz compinit
compinit
print -r -- ${_comps[(I)-value-*]}
";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let binding = binding_for_name(&model, "_comps");

    assert!(matches!(binding.kind, BindingKind::Imported));
    assert!(binding.attributes.contains(BindingAttributes::ARRAY));
    assert!(binding.attributes.contains(BindingAttributes::ASSOC));
    assert!(matches!(
        binding.origin,
        BindingOrigin::BuiltinTarget {
            kind: BuiltinBindingTargetKind::Compinit,
            ..
        }
    ));
    assert_eq!(binding.references.len(), 1);
    assert_eq!(
        model.reference(binding.references[0]).name.as_str(),
        "_comps"
    );
    assert_names_absent(&["_comps"], &uninitialized_names(&model));
}

#[test]
fn zparseopts_attached_and_separator_targets_are_precise() {
    let source = "zparseopts -aall -Aassoc -- -long=long_target s=short_target\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let targets = model
        .bindings()
        .iter()
        .filter(|binding| matches!(binding.kind, BindingKind::ZparseoptsTarget))
        .map(|binding| {
            (
                binding.name.as_str().to_owned(),
                binding.span.slice(source).to_owned(),
                binding.attributes.contains(BindingAttributes::ASSOC),
            )
        })
        .collect::<Vec<_>>();

    assert_eq!(
        targets,
        vec![
            ("all".to_owned(), "all".to_owned(), false),
            ("assoc".to_owned(), "assoc".to_owned(), true),
            ("long_target".to_owned(), "long_target".to_owned(), false),
            ("short_target".to_owned(), "short_target".to_owned(), false),
        ]
    );
}

#[test]
fn zparseopts_control_options_are_not_stacked() {
    let source = "zparseopts -- -DEK=dest\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let binding = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "dest" && matches!(binding.kind, BindingKind::ZparseoptsTarget)
        })
        .expect("expected -DEK spec target");

    assert_eq!(binding.span.slice(source), "dest");
    assert!(binding.attributes.contains(BindingAttributes::ARRAY));
}

#[test]
fn zparseopts_escaped_equals_in_spec_name_is_not_a_target_separator() {
    let source = "zparseopts -a opts -- foo\\=bar foo\\=baz=dest\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let targets = model
        .bindings()
        .iter()
        .filter(|binding| matches!(binding.kind, BindingKind::ZparseoptsTarget))
        .map(|binding| binding.name.as_str().to_owned())
        .collect::<Vec<_>>();

    assert_eq!(targets, vec!["opts".to_owned(), "dest".to_owned()]);
}

#[test]
fn zparseopts_mapping_does_not_bind_targets_that_name_specs() {
    let source = "zparseopts -A bar -M a=foo b+: c:=b\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    let targets = model
        .bindings()
        .iter()
        .filter(|binding| matches!(binding.kind, BindingKind::ZparseoptsTarget))
        .map(|binding| {
            (
                binding.name.as_str().to_owned(),
                binding.attributes.contains(BindingAttributes::ASSOC),
            )
        })
        .collect::<Vec<_>>();

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

#[test]
fn zparseopts_dynamic_targets_do_not_create_static_bindings() {
    let source = "zparseopts -a$aggregate -- x=$target\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));

    assert!(
        !model
            .bindings()
            .iter()
            .any(|binding| matches!(binding.kind, BindingKind::ZparseoptsTarget))
    );
}

#[test]
fn zparseopts_targets_are_zsh_only() {
    let source = "zparseopts -- x=target\n";
    let model = model_with_dialect(source, ShellDialect::Bash);

    assert!(
        !model
            .bindings()
            .iter()
            .any(|binding| matches!(binding.kind, BindingKind::ZparseoptsTarget))
    );
}

#[test]
fn zsh_function_reply_assignment_is_marked_externally_consumed() {
    let source = "\
#!/bin/zsh
helper() {
  REPLY=value
}
";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    let binding = binding_for_name(&model, "REPLY");
    assert!(
        binding
            .attributes
            .contains(BindingAttributes::EXTERNALLY_CONSUMED),
        "{binding:?}"
    );
    assert!(
        !binding_names(&model, model.analysis().unused_assignments()).contains(&"REPLY".to_owned())
    );
}

#[test]
fn zsh_local_reply_assignment_stays_local() {
    let source = "\
#!/bin/zsh
helper() {
  local REPLY=value
}
";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    let binding = binding_for_name(&model, "REPLY");
    assert!(
        !binding
            .attributes
            .contains(BindingAttributes::EXTERNALLY_CONSUMED)
    );
}

#[test]
fn zsh_reply_assignment_stays_local_until_local_is_cleared() {
    let source = "\
#!/bin/zsh
helper() {
  local REPLY
  REPLY=value
  unset REPLY
}
";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    let reply_bindings = model.bindings_for(&Name::from("REPLY"));
    let assignment = model.binding(*reply_bindings.last().expect("assignment binding"));
    assert!(
        !assignment
            .attributes
            .contains(BindingAttributes::EXTERNALLY_CONSUMED)
    );
    assert!(
        binding_names(&model, model.analysis().unused_assignments()).contains(&"REPLY".to_owned())
    );
}

#[test]
fn mapfile_missing_option_operand_does_not_panic() {
    let source = "mapfile -u\n";
    let model = model(source);

    assert!(
        !model
            .bindings()
            .iter()
            .any(|binding| matches!(binding.kind, BindingKind::MapfileTarget))
    );
}

#[test]
fn read_header_bindings_consumed_in_loop_body_are_live() {
    let source = "\
printf '%s\n' 'service safe ok yes' | while read UNIT EXPOSURE PREDICATE HAPPY; do
  printf '%s %s %s %s\n' \"$UNIT\" \"$EXPOSURE\" \"$PREDICATE\" \"$HAPPY\"
done
";
    let model = model(source);
    let unused = reportable_unused_names(&model);

    for name in ["UNIT", "EXPOSURE", "PREDICATE", "HAPPY"] {
        assert!(
            !unused.contains(&Name::from(name)),
            "unused bindings: {:?}",
            unused
        );
    }
}

#[test]
fn command_prefix_assignments_do_not_create_shell_bindings() {
    let source = "\
base_flags=1
CFLAGS=\"$base_flags\" make
echo \"$CFLAGS\"
";
    let model = model(source);

    assert!(
        model
            .bindings()
            .iter()
            .all(|binding| binding.name != "CFLAGS")
    );

    let cflags_reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "CFLAGS")
        .unwrap();
    assert!(model.resolved_binding(cflags_reference.id).is_none());
    assert!(model.unresolved_references().contains(&cflags_reference.id));
}

#[test]
fn indirect_expansion_keeps_dynamic_target_arrays_live() {
    let source = "\
#!/bin/bash
apache_args=(--apache)
nginx_args=(--nginx)
apache_args+=(--common)
nginx_args+=(--common)
web_server=apache
args_var=\"${web_server}_args[@]\"
printf '%s\\n' \"${!args_var}\"
";
    let model = model(source);
    model.analysis().dataflow();

    let unused = model
        .analysis()
        .unused_assignments()
        .iter()
        .map(|binding| model.binding(*binding).name.as_str())
        .collect::<Vec<_>>();
    assert!(!unused.contains(&"apache_args"));
    assert!(!unused.contains(&"nginx_args"));

    let carrier = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "args_var")
        .unwrap();
    let reference = model
        .references()
        .iter()
        .find(|reference| {
            reference.kind == ReferenceKind::IndirectExpansion && reference.name == "args_var"
        })
        .unwrap();

    let mut carrier_targets = binding_names(&model, model.indirect_targets_for_binding(carrier.id));
    carrier_targets.sort();
    carrier_targets.dedup();
    assert_eq!(carrier_targets, vec!["apache_args", "nginx_args"]);

    let mut reference_targets =
        binding_names(&model, model.indirect_targets_for_reference(reference.id));
    reference_targets.sort();
    reference_targets.dedup();
    assert_eq!(reference_targets, vec!["apache_args", "nginx_args"]);
}

#[test]
fn append_assignments_contribute_to_later_array_expansion() {
    let source = "\
#!/bin/bash
arr=(--first)
arr+=(--second)
printf '%s\\n' \"${arr[@]}\"
";
    let model = model(source);
    model.analysis().dataflow();

    let unused = model
        .analysis()
        .unused_assignments()
        .iter()
        .map(|binding| model.binding(*binding).name.as_str())
        .collect::<Vec<_>>();
    assert!(!unused.contains(&"arr"));
}

#[test]
fn process_substitution_reads_keep_outer_assignments_live() {
    let source = "\
#!/bin/bash
f() {
  local opts
  case \"$1\" in
    a) opts=alpha ;;
    *) opts=beta ;;
  esac
  while IFS= read -r line; do :; done < <(printf '%s\\n' \"$opts\")
}
f a
";
    let model = model(source);
    let unused = reportable_unused_names(&model);

    assert!(
        !unused.contains(&Name::from("opts")),
        "unused: {:?}",
        unused
    );
    assert!(unused.contains(&Name::from("line")), "unused: {:?}", unused);
}

#[test]
fn overwritten_empty_initializers_do_not_report_the_placeholder_assignment() {
    let plain = model(
        "\
#!/bin/bash
foo=
foo=bar
",
    );
    let plain_unused = plain.analysis().unused_assignments().to_vec();
    assert_eq!(plain_unused.len(), 1);
    assert_eq!(plain.binding(plain_unused[0]).span.start.line(), 3);

    let local = model(
        "\
#!/bin/bash
f() {
  local foo=
  foo=bar
}
f
",
    );
    let local_unused = local.analysis().unused_assignments().to_vec();
    assert_eq!(local_unused.len(), 1);
    assert_eq!(local.binding(local_unused[0]).span.start.line(), 4);
}

#[test]
fn associative_compound_declaration_marks_binding_assoc_and_array() {
    let model = model("#!/bin/bash\ndeclare -A assoc=(one [foo]=bar [bar]+=baz)\n");

    let assoc = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "assoc")
        .expect("expected assoc binding");
    assert!(assoc.attributes.contains(BindingAttributes::ARRAY));
    assert!(assoc.attributes.contains(BindingAttributes::ASSOC));
}

#[test]
fn read_implicitly_consumes_visible_ifs_binding() {
    let source = "\
#!/bin/bash
f() {
  local IFS=$'\\n'
  local unused=1
  read -d '' -ra reply < <(printf 'alpha\\nbeta\\0')
  printf '%s\\n' \"${reply[@]}\"
}
f
";
    let model = model(source);
    model.analysis().dataflow();

    assert!(model.references().iter().any(|reference| {
        reference.name == "IFS" && matches!(reference.kind, ReferenceKind::ImplicitRead)
    }));

    let unused = model
        .analysis()
        .unused_assignments()
        .iter()
        .map(|binding| model.binding(*binding).name.as_str())
        .collect::<Vec<_>>();
    assert!(!unused.contains(&"IFS"));
    assert!(unused.contains(&"unused"));
}

#[test]
fn reaching_bindings_lookup_picks_later_same_name_reference() {
    let source = "\
#!/bin/bash
foo=1
printf '%s\\n' \"$foo\"
foo=2
printf '%s\\n' \"$foo\"
";
    let model = model(source);
    let foo_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .map(|binding| binding.id)
        .collect::<Vec<_>>();
    assert_eq!(foo_bindings.len(), 2);

    let target_reference = model
        .references()
        .iter()
        .rev()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let reaching = model
        .analysis()
        .reaching_bindings_for_name(&target_reference.name, target_reference.span);

    assert_eq!(reaching, vec![foo_bindings[1]]);
}

#[test]
fn semantic_analysis_exposes_binding_and_reference_flow_blocks() {
    let source = "\
#!/bin/bash
foo=1
printf '%s\\n' \"$foo\"
";
    let model = model(source);
    let binding = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .expect("expected foo binding");
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();

    assert_eq!(
        analysis.reference_id_for_name_at(&reference.name, reference.span),
        Some(reference.id)
    );
    assert!(analysis.block_for_binding(binding.id).is_some());
    assert!(analysis.block_for_reference_id(reference.id).is_some());
    assert!(analysis.binding_dominates_reference_from_flow_entry(
        binding.id,
        &reference.name,
        reference.span,
        true
    ));
}

#[test]
fn semantic_block_coverage_detects_uncovered_branch_paths() {
    let source = "\
#!/bin/bash
if cond; then
  foo=1
fi
printf '%s\\n' \"$foo\"
";
    let model = model(source);
    let binding = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .expect("expected foo binding");
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();
    let binding_block = analysis
        .block_for_binding(binding.id)
        .expect("expected binding block");
    let reference_block = analysis
        .block_for_reference_id(reference.id)
        .expect("expected reference block");
    let entry = analysis
        .flow_entry_block_for_binding_scopes(&[binding.scope], reference.span.start.offset());
    let cover_blocks = FxHashSet::from_iter([binding_block]);

    assert!(!analysis.blocks_cover_all_paths_to_block(entry, reference_block, &cover_blocks));
    assert!(!analysis.binding_dominates_reference_from_flow_entry(
        binding.id,
        &reference.name,
        reference.span,
        true
    ));
}

#[test]
fn value_flow_returns_real_reference_reaching_value_bindings() {
    let source = "\
#!/bin/bash
foo=1
foo=2
printf '%s\\n' \"$foo\"
";
    let model = model(source);
    let bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .map(|binding| binding.id)
        .collect::<Vec<_>>();
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();
    let value_flow = analysis.value_flow();

    assert_eq!(
        value_flow.reaching_value_bindings_for_name(&reference.name, reference.span),
        vec![bindings[1]]
    );
}

#[test]
fn value_flow_can_check_direct_references_against_candidate_bindings() {
    for (source, expected) in [
        (
            "\
#!/bin/bash
foo=1
if cond; then
  foo=(a b)
fi
printf '%s\\n' \"$foo\"
",
            true,
        ),
        (
            "\
#!/bin/bash
foo=(a b)
foo=1
printf '%s\\n' \"$foo\"
",
            false,
        ),
    ] {
        let model = model(source);
        let reference = model
            .references()
            .iter()
            .find(|reference| reference.name == "foo")
            .expect("expected foo reference");
        let candidate_bindings = model
            .bindings()
            .iter()
            .filter(|binding| binding.name == "foo")
            .filter(|binding| {
                binding
                    .attributes
                    .intersects(BindingAttributes::ARRAY | BindingAttributes::ASSOC)
                    || matches!(
                        binding.kind,
                        BindingKind::ArrayAssignment | BindingKind::MapfileTarget
                    )
            })
            .map(|binding| binding.id)
            .collect::<Vec<_>>();
        let analysis = model.analysis();
        let value_flow = analysis.value_flow();

        assert_eq!(
            value_flow.reference_reaches_any_value_binding(reference.id, &candidate_bindings),
            expected,
            "{source}"
        );
        assert_eq!(
            value_flow
                .reaching_value_bindings_for_name(&reference.name, reference.span)
                .into_iter()
                .any(|binding_id| candidate_bindings.contains(&binding_id)),
            expected,
            "{source}"
        );
    }
}

#[test]
fn value_flow_returns_synthetic_use_site_value_bindings() {
    let source = "\
#!/bin/bash
foo=1
if cond; then
  foo=2
fi
printf done
";
    let model = model(source);
    let printf = command_id_starting_with(&model, source, "printf").expect("expected printf");
    let bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .map(|binding| binding.id)
        .collect::<Vec<_>>();
    let analysis = model.analysis();
    let value_flow = analysis.value_flow();

    assert_eq!(
        value_flow.reaching_value_bindings_for_name(&Name::from("foo"), model.command_span(printf)),
        bindings
    );
}

#[test]
fn value_flow_can_skip_reference_lookup_for_known_synthetic_use_sites() {
    let source = "\
#!/bin/bash
foo=1
if cond; then
  foo=2
fi
printf done
";
    let model = model(source);
    let printf = command_id_starting_with(&model, source, "printf").expect("expected printf");
    let analysis = model.analysis();
    let value_flow = analysis.value_flow();
    let use_span = model.command_span(printf);
    let synthetic_use_block = analysis.flow_entry_block_for_binding_scopes(
        &[model.scope_at(use_span.start.offset())],
        use_span.start.offset(),
    );

    assert_eq!(
        value_flow.reaching_value_bindings_for_name_without_reference(
            &Name::from("foo"),
            use_span,
            synthetic_use_block,
        ),
        value_flow.reaching_value_bindings_for_name_with_synthetic_use_block(
            &Name::from("foo"),
            use_span,
            Some(synthetic_use_block),
        )
    );
}

#[test]
fn value_flow_direct_reference_tracks_zsh_unquoted_array_fanout() {
    let source = "\
#!/bin/zsh
foo=(a b)
print $foo
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();
    let value_flow = analysis.value_flow();

    assert!(value_flow.reference_can_fan_out_when_unquoted(reference.id));

    let disabled_source = "\
#!/bin/zsh
setopt ksh_arrays
foo=(a b)
print $foo
";
    let disabled_model =
        model_with_profile(disabled_source, ShellProfile::native(ShellDialect::Zsh));
    let disabled_reference = disabled_model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let disabled_analysis = disabled_model.analysis();
    let disabled_value_flow = disabled_analysis.value_flow();

    assert!(!disabled_value_flow.reference_can_fan_out_when_unquoted(disabled_reference.id));
}

#[test]
fn value_flow_synthetic_use_site_tracks_zsh_unquoted_array_fanout() {
    let source = "\
#!/bin/zsh
foo=(a b)
printf done
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let printf = command_id_starting_with(&model, source, "printf").expect("expected printf");
    let use_span = model.command_span(printf);
    let analysis = model.analysis();
    let value_flow = analysis.value_flow();

    assert!(value_flow.name_can_fan_out_when_unquoted_without_reference(
        &Name::from("foo"),
        use_span,
        model.scope_at(use_span.start.offset()),
    ));

    let scalar_source = "\
#!/bin/zsh
foo=scalar
printf done
";
    let scalar_model = model_with_profile(scalar_source, ShellProfile::native(ShellDialect::Zsh));
    let scalar_printf =
        command_id_starting_with(&scalar_model, scalar_source, "printf").expect("expected printf");
    let scalar_use_span = scalar_model.command_span(scalar_printf);
    let scalar_analysis = scalar_model.analysis();
    let scalar_value_flow = scalar_analysis.value_flow();

    assert!(
        !scalar_value_flow.name_can_fan_out_when_unquoted_without_reference(
            &Name::from("foo"),
            scalar_use_span,
            scalar_model.scope_at(scalar_use_span.start.offset()),
        )
    );
}

#[test]
fn value_flow_can_bypass_one_reaching_binding() {
    let source = "\
#!/bin/bash
foo=1
if cond; then
  foo=2
fi
printf '%s\\n' \"$foo\"
";
    let model = model(source);
    let bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .map(|binding| binding.id)
        .collect::<Vec<_>>();
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();
    let value_flow = analysis.value_flow();

    assert_eq!(
        value_flow.reaching_value_bindings_bypassing(&reference.name, bindings[1], reference.span),
        vec![bindings[0]]
    );
}

#[test]
fn value_flow_returns_ancestor_bindings_before_scope_site() {
    let source = "\
#!/bin/bash
foo=1
use() {
  printf '%s\\n' \"$foo\"
}
";
    let model = model(source);
    let binding = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .expect("expected foo binding");
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();
    let value_flow = analysis.value_flow();

    assert_eq!(
        value_flow.ancestor_value_bindings_before(&reference.name, reference.scope, reference.span),
        vec![binding.id]
    );
}

#[test]
fn value_flow_returns_nonlocal_bindings_from_called_functions() {
    let source = "\
#!/bin/bash
setfoo() {
  foo=1
}
use() {
  setfoo
  printf '%s\\n' \"$foo\"
}
";
    let model = model(source);
    let binding = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .expect("expected foo binding");
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();
    let mut value_flow = analysis.value_flow();

    assert_eq!(
        value_flow.nonlocal_value_bindings_from_called_functions_before(
            &reference.name,
            reference.scope,
            reference.span,
        ),
        vec![binding.id]
    );
}

#[test]
fn value_flow_ignores_conditionally_installed_called_functions() {
    let source = "\
#!/bin/bash
if cond; then
  setfoo() {
    foo=1
  }
fi
use() {
  setfoo
  printf '%s\\n' \"$foo\"
}
";
    let model = model(source);
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();
    let mut value_flow = analysis.value_flow();

    assert!(
        value_flow
            .nonlocal_value_bindings_from_called_functions_before(
                &reference.name,
                reference.scope,
                reference.span,
            )
            .is_empty()
    );
}

#[test]
fn value_flow_uses_visible_top_level_functions_from_function_bodies() {
    let source = "\
#!/bin/bash
use() {
  setfoo
  printf '%s\\n' \"$foo\"
}
setfoo() {
  foo=1
}
use
";
    let model = model(source);
    let binding = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .expect("expected foo binding");
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();
    let mut value_flow = analysis.value_flow();

    assert_eq!(
        value_flow.nonlocal_value_bindings_from_called_functions_before(
            &reference.name,
            reference.scope,
            reference.span,
        ),
        vec![binding.id]
    );
}

#[test]
fn value_flow_resolves_multi_name_function_alias_fallbacks() {
    let source = "\
#!/bin/zsh
use() {
  itunes
  print $foo
}
function music itunes() {
  foo=1
}
use
";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let binding = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .expect("expected foo binding");
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();
    let mut value_flow = analysis.value_flow();

    assert_eq!(
        value_flow.nonlocal_value_bindings_from_called_functions_before(
            &reference.name,
            reference.scope,
            reference.span,
        ),
        vec![binding.id]
    );
}

#[test]
fn value_flow_returns_helper_bindings_visible_at_reference() {
    let source = "\
#!/bin/bash
setfoo() {
  foo=1
}
use() {
  setfoo
  printf '%s\\n' \"$foo\"
}
";
    let model = model(source);
    let binding = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .expect("expected foo binding");
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();
    let mut value_flow = analysis.value_flow();

    assert_eq!(
        value_flow.helper_value_bindings_before(&reference.name, reference.span),
        vec![binding.id]
    );
}

#[test]
fn value_flow_named_function_call_sites_resolve_alias_fallbacks() {
    let source = "\
#!/bin/zsh
use() {
  itunes
  print $foo
}
function music itunes() {
  foo=1
}
use
";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let helper_scope = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .map(|binding| binding.scope)
        .expect("expected helper scope");
    let analysis = model.analysis();
    let value_flow = analysis.value_flow();
    let call_sites = value_flow.named_function_call_sites(helper_scope);

    assert_eq!(call_sites.len(), 1);
    assert_eq!(call_sites[0].callee.as_str(), "itunes");
}

#[test]
fn value_flow_tracks_transitive_called_function_scopes_before() {
    let source = "\
#!/bin/bash
first() {
  second
}
second() {
  foo=1
}
first
printf '%s\\n' \"$foo\"
";
    let model = model(source);
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let helper_scope = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .map(|binding| binding.scope)
        .expect("expected helper scope");
    let analysis = model.analysis();
    let value_flow = analysis.value_flow();
    let caller_scope = model.scope_at(reference.span.start.offset());
    let callee_scopes = value_flow
        .transitively_called_function_scopes_before(caller_scope, reference.span.start.offset());

    assert!(callee_scopes.contains(&helper_scope));
}

#[test]
fn value_flow_uses_path_covering_alternate_function_definitions() {
    let source = "\
#!/bin/bash
if cond; then
  setfoo() {
    foo=1
  }
else
  setfoo() {
    foo=2
  }
fi
use() {
  setfoo
  printf '%s\\n' \"$foo\"
}
";
    let model = model(source);
    let bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .map(|binding| binding.id)
        .collect::<Vec<_>>();
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "foo")
        .expect("expected foo reference");
    let analysis = model.analysis();
    let mut value_flow = analysis.value_flow();

    assert_eq!(
        value_flow.nonlocal_value_bindings_from_called_functions_before(
            &reference.name,
            reference.scope,
            reference.span,
        ),
        bindings
    );
}

#[test]
fn value_flow_detects_binding_paths_do_not_cover_span() {
    let source = "\
#!/bin/bash
if cond; then
  foo=1
fi
printf '%s\\n' \"$foo\"
";
    let model = model(source);
    let binding = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "foo" && matches!(binding.kind, BindingKind::Assignment))
        .expect("expected foo binding");
    let printf = command_id_starting_with(&model, source, "printf").expect("expected printf");
    let analysis = model.analysis();
    let value_flow = analysis.value_flow();

    assert!(
        !value_flow
            .value_bindings_cover_all_paths_to_span(&[binding.id], model.command_span(printf))
    );
}

#[test]
fn ifs_assignments_are_treated_as_implicitly_used() {
    let source = "\
#!/bin/bash
IFS=$'\\n\\t'
unused=1
echo ok
";
    let model = model(source);
    model.analysis().dataflow();

    let unused = model
        .analysis()
        .unused_assignments()
        .iter()
        .map(|binding| model.binding(*binding).name.as_str())
        .collect::<Vec<_>>();
    assert!(!unused.contains(&"IFS"));
    assert!(unused.contains(&"unused"));
}

#[test]
fn shell_runtime_assignments_are_treated_as_implicitly_used() {
    let source = "\
#!/bin/sh
PATH=$PATH:/opt/custom
CDPATH=/tmp
LANG=C
LC_ALL=C
LC_TIME=C
unused=1
echo ok
";
    let model = model(source);
    model.analysis().dataflow();

    let unused = model
        .analysis()
        .unused_assignments()
        .iter()
        .map(|binding| model.binding(*binding).name.as_str())
        .collect::<Vec<_>>();
    for name in ["PATH", "CDPATH", "LANG", "LC_ALL", "LC_TIME"] {
        assert!(!unused.contains(&name), "unused bindings: {:?}", unused);
    }
    assert!(unused.contains(&"unused"));
}

#[test]
fn special_runtime_assignments_are_treated_as_implicitly_used() {
    let source = "\
#!/bin/bash
HOME=/tmp/home
SHELL=/bin/bash
TERM=xterm-256color
USER=builder
PWD=/tmp/work
HISTFILE=/tmp/history
HISTFILESIZE=unlimited
HISTIGNORE='ls:bg:fg:history'
HISTSIZE=-1
HISTTIMEFORMAT='%F %T '
COMP_WORDBREAKS=\"${COMP_WORDBREAKS//:/}\"
PROMPT_COMMAND='history -a'
BASH_ENV=/dev/null
BASH_XTRACEFD=9
ENV=/dev/null
INPUTRC=/tmp/inputrc
MAIL=/tmp/mail
OLDPWD=/tmp/old
PROMPT_DIRTRIM=2
SECONDS=0
TIMEFORMAT='%R'
TMOUT=30
PS1='prompt> '
PS2='continuation> '
PS3=''
PS4='+ '
COLUMNS=1
READLINE_POINT=0
unused=1
";
    let model = model(source);
    model.analysis().dataflow();

    let unused = model
        .analysis()
        .unused_assignments()
        .iter()
        .map(|binding| model.binding(*binding).name.as_str())
        .collect::<Vec<_>>();
    for name in [
        "HOME",
        "SHELL",
        "TERM",
        "USER",
        "PWD",
        "OPTIND",
        "OPTARG",
        "OPTERR",
        "HISTFILE",
        "HISTFILESIZE",
        "HISTIGNORE",
        "HISTSIZE",
        "HISTTIMEFORMAT",
        "COMP_WORDBREAKS",
        "PROMPT_COMMAND",
        "BASH_ENV",
        "BASH_XTRACEFD",
        "ENV",
        "INPUTRC",
        "MAIL",
        "OLDPWD",
        "PROMPT_DIRTRIM",
        "SECONDS",
        "TIMEFORMAT",
        "TMOUT",
        "PS1",
        "PS2",
        "PS3",
        "PS4",
        "COLUMNS",
        "READLINE_POINT",
    ] {
        assert!(!unused.contains(&name), "unused bindings: {:?}", unused);
    }
    assert!(unused.contains(&"unused"));
}

#[test]
fn escaped_ps4_prompt_references_are_read_at_the_assignment_site() {
    let source = "\
#!/bin/bash
export PS4=\"+ \\${BASH_SOURCE##\\${rvm_path:-}} > \"
p=\"$rvm_path\"
";
    let model = model(source);
    let analysis = model.analysis();
    let prompt_reference = analysis
        .uninitialized_references()
        .iter()
        .map(|uninitialized| model.reference(uninitialized.reference))
        .find(|reference| reference.name == "rvm_path" && reference.span.slice(source) == "PS4");

    assert!(prompt_reference.is_some());
}

#[test]
fn trap_action_references_are_read_at_the_action_word() {
    let source = "\
#!/bin/sh
tmpdir=/tmp/example
trap 'ret=$?; rmdir \"$tmpdir/d\" \"$tmpdir\" 2>/dev/null; exit $ret' 0
";
    let model = model(source);
    let analysis = model.analysis();
    let trap_reference = analysis
        .uninitialized_references()
        .iter()
        .map(|uninitialized| model.reference(uninitialized.reference))
        .find(|reference| {
            reference.name == "ret"
                && reference.kind == ReferenceKind::TrapAction
                && reference.span.slice(source)
                    == "'ret=$?; rmdir \"$tmpdir/d\" \"$tmpdir\" 2>/dev/null; exit $ret'"
        });

    assert!(trap_reference.is_some());
}

#[test]
fn bash_completion_runtime_vars_are_treated_as_live() {
    let source = "\
#!/bin/bash
_pyenv() {
  COMPREPLY=()
  local word=\"${COMP_WORDS[COMP_CWORD]}\"
  COMPREPLY=( $(compgen -W \"$(printf 'a b')\" -- \"$word\") )
}
complete -F _pyenv pyenv
";
    let model = model(source);
    model.analysis().dataflow();

    let unused = model
        .analysis()
        .unused_assignments()
        .iter()
        .map(|binding| model.binding(*binding).name.as_str())
        .collect::<Vec<_>>();
    assert!(!unused.contains(&"COMPREPLY"));

    let uninitialized = uninitialized_names(&model);
    assert!(!uninitialized.contains(&"COMP_WORDS".to_string()));
    assert!(!uninitialized.contains(&"COMP_CWORD".to_string()));
}

#[test]
fn exact_indirect_expansion_does_not_keep_unrelated_array_live() {
    let source = "\
#!/bin/bash
apache_args=(--apache)
unused_args=(--unused)
args_var=apache_args[@]
printf '%s\\n' \"${!args_var}\"
";
    let model = model(source);
    model.analysis().dataflow();

    let unused = model
        .analysis()
        .unused_assignments()
        .iter()
        .map(|binding| model.binding(*binding).name.as_str())
        .collect::<Vec<_>>();
    assert!(!unused.contains(&"apache_args"));
    assert!(unused.contains(&"unused_args"));

    let reference = model
        .references()
        .iter()
        .find(|reference| {
            reference.kind == ReferenceKind::IndirectExpansion && reference.name == "args_var"
        })
        .unwrap();
    let targets = binding_names(&model, model.indirect_targets_for_reference(reference.id));
    assert_eq!(targets, vec!["apache_args"]);
}

#[test]
fn exact_indirect_target_resolution_tracks_underlying_binding() {
    let source = "\
#!/bin/bash
target=ok
name=target
printf '%s\\n' \"${!name}\"
";
    let model = model(source);

    let carrier = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "name")
        .unwrap();
    let reference = model
        .references()
        .iter()
        .find(|reference| {
            reference.kind == ReferenceKind::IndirectExpansion && reference.name == "name"
        })
        .unwrap();

    assert_eq!(
        binding_names(&model, model.indirect_targets_for_binding(carrier.id)),
        vec!["target"]
    );
    assert_eq!(
        binding_names(&model, model.indirect_targets_for_reference(reference.id)),
        vec!["target"]
    );
}

#[test]
fn unused_assignment_flags_indirect_only_targets_by_default() {
    let source = "\
#!/bin/bash
target=ok
name=target
other=1
printf '%s\\n' \"${!name}\"
";
    let model = model(source);
    let default_unused = binding_names(&model, model.analysis().unused_assignments());
    let live_indirect_target_unused = binding_names(
        &model,
        model
            .analysis()
            .unused_assignments_with_options(UnusedAssignmentAnalysisOptions {
                treat_indirect_expansion_targets_as_used: true,
                report_unreachable_assignments: false,
            }),
    );

    assert_eq!(default_unused, vec!["target", "other"]);
    assert_eq!(live_indirect_target_unused, vec!["other"]);
}

#[test]
fn unused_assignment_options_keep_array_like_indirect_targets_live() {
    let source = "\
#!/bin/bash
apache_args=(--apache)
unused_args=(--unused)
args_var=apache_args[@]
printf '%s\\n' \"${!args_var}\"
";
    let model = model(source);
    let default_unused = binding_names(&model, model.analysis().unused_assignments());
    let compat_unused = binding_names(
        &model,
        model
            .analysis()
            .unused_assignments_with_options(UnusedAssignmentAnalysisOptions {
                treat_indirect_expansion_targets_as_used: false,
                report_unreachable_assignments: false,
            }),
    );

    assert_eq!(default_unused, vec!["unused_args"]);
    assert_eq!(compat_unused, vec!["unused_args"]);
}

#[test]
fn unused_assignment_options_preserve_heuristic_fast_path_without_indirect_targets() {
    let model = model("unused=1\n");
    let analysis = model.analysis();

    let compat_unused = analysis
        .unused_assignments_with_options(UnusedAssignmentAnalysisOptions {
            treat_indirect_expansion_targets_as_used: false,
            report_unreachable_assignments: false,
        })
        .to_vec();

    assert!(analysis.exact_variable_dataflow.get().is_none());
    assert!(analysis.dataflow.get().is_none());
    assert_eq!(binding_names(&model, &compat_unused), vec!["unused"]);
}

#[test]
fn call_graph_and_zsh_option_analysis_are_lazy_until_queried() {
    let model = model_with_profile("echo hi\n", ShellProfile::native(ShellDialect::Zsh));

    assert!(model.call_graph.get().is_none());
    assert!(model.zsh_option_analysis.get().is_none());

    model.call_graph();
    assert!(model.call_graph.get().is_some());
    assert!(model.zsh_option_analysis.get().is_none());

    model.zsh_options_at(0);
    assert!(model.zsh_option_analysis.get().is_some());
}

#[test]
fn unused_assignment_options_can_report_unreachable_assignments() {
    let source = "\
#!/bin/bash
f() {
  return 1
  dead=1
  used=1
  printf '%s\\n' \"$used\"
}
f
";
    let model = model(source);
    let default_unused = binding_names(&model, model.analysis().unused_assignments());
    let compat_unused = binding_names(
        &model,
        model
            .analysis()
            .unused_assignments_with_options(UnusedAssignmentAnalysisOptions {
                treat_indirect_expansion_targets_as_used: false,
                report_unreachable_assignments: true,
            }),
    );

    assert!(!default_unused.iter().any(|name| name == "dead"));
    assert_eq!(compat_unused, vec!["dead"]);
}

#[test]
fn resolved_indirect_expansion_carrier_is_not_marked_uninitialized() {
    let source = "\
#!/bin/bash
f() {
  local carrier
  echo \"${!carrier}\"
}
f
";
    let model = model(source);
    assert!(uninitialized_names(&model).is_empty());
}

#[test]
fn prefix_name_expansions_do_not_read_the_prefix_as_a_variable() {
    let source = "unset \"${!completion_prefix@}\"\nprintf '%s\\n' \"$ordinary_missing\"\n";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&["completion_prefix"], &uninitialized);
    assert_names_present(&["ordinary_missing"], &uninitialized);
}

#[test]
fn guarded_parameter_expansions_are_not_marked_uninitialized() {
    let source = "\
printf '%s\\n' \
  \"${missing_default:-fallback}\" \
  \"${missing_assign:=value}\" \
  \"${missing_replace:+alt}\" \
  \"${missing_error:?missing}\"
";
    let model = model(source);
    let unresolved = unresolved_names(&model);
    let uninitialized = uninitialized_names(&model);

    assert_names_present(
        &[
            "missing_default",
            "missing_assign",
            "missing_replace",
            "missing_error",
        ],
        &unresolved,
    );
    assert_names_absent(
        &[
            "missing_default",
            "missing_assign",
            "missing_replace",
            "missing_error",
        ],
        &uninitialized,
    );
}

#[test]
fn unquoted_guarded_parameter_expansions_are_not_marked_uninitialized() {
    let source = "\
eval start-stop-daemon --start \\
  ${directory:+--chdir} $directory
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&["directory"], &uninitialized);
}

#[test]
fn self_referential_assignments_are_not_marked_uninitialized() {
    let source = "\
foo=\"$foo\"
for flag in a b; do
  valid_flags=\"${valid_flags} $flag\"
done
foo[$foo]=x
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&["foo", "valid_flags"], &uninitialized);
}

#[test]
fn escaped_declaration_builtins_initialize_dynamic_assignment_operands() {
    let source = "\
\\typeset ret=$?
printf '%s\\n' \"$ret\"
";
    let model = model_with_dialect(source, ShellDialect::Bash);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&["ret"], &uninitialized);
}

#[test]
fn arithmetic_conditional_literal_operands_are_uninitialized_reads() {
    let source = "\
version=1
if [[ $version -eq \"latest\" ]]; then
  :
fi
if [[ 1 -ne bare ]]; then
  :
fi
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_present(&["latest", "bare"], &uninitialized);
}

#[test]
fn let_arithmetic_assignments_initialize_targets() {
    let source = "\
#!/bin/bash
let line=\"$number\"+1
printf '%s\\n' \"$line\"
";
    let model = model_with_dialect(source, ShellDialect::Bash);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&["line"], &uninitialized);
    assert_names_present(&["number"], &uninitialized);
}

#[test]
fn assignment_values_continue_after_escaped_newlines() {
    let source = "\
#!/bin/sh
easyrsa_ksh=\\
'value'
[ \"${KSH_VERSION}\" = \"${easyrsa_ksh}\" ] && echo ok
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&["easyrsa_ksh"], &uninitialized);
}

#[test]
fn special_zero_prefix_removal_inside_escaped_quotes_does_not_synthesize_empty_references() {
    let source = "\
#!/bin/bash
usage=\"
Terraform:

    data \\\"external\\\" \\\"github_repos\\\" {
        program = [\\\"/path/to/${0##*/}\\\", \\\"github_repository\\\"]
    }
\"
";
    let model = model_with_dialect(source, ShellDialect::Bash);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&[""], &uninitialized);
}

#[test]
fn unparsed_indexed_subscript_prefixes_are_uninitialized_reads() {
    let source = "\
arr+=([docker:dind]=x [nats-streaming:nanoserver]=y)
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_present(&["docker", "nats", "streaming"], &uninitialized);
    assert_names_absent(&["dind", "nanoserver"], &uninitialized);
}

#[test]
fn escaped_heredoc_parameter_literals_still_expand_nested_references() {
    let source = "\
cat <<EOF
\\${OUTER:-$inner}
EOF
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_present(&["inner"], &uninitialized);
    assert_names_absent(&["OUTER"], &uninitialized);
}

#[test]
fn assign_default_and_error_operands_are_marked_uninitialized() {
    let source = "\
printf '%s\\n' \
  \"${missing_default:-$fallback_name}\" \
  \"${missing_assign:=$seed_name}\" \
  \"${missing_replace:+$replacement_name}\" \
  \"${missing_error:?$hint_name}\"
";
    let model = model(source);
    let unresolved = unresolved_names(&model);
    let uninitialized = uninitialized_names(&model);

    assert_names_present(
        &[
            "fallback_name",
            "seed_name",
            "replacement_name",
            "hint_name",
        ],
        &unresolved,
    );
    assert_names_absent(&["fallback_name", "replacement_name"], &uninitialized);
    assert_names_present(&["seed_name", "hint_name"], &uninitialized);
}

#[test]
fn parameter_slice_arithmetic_operands_are_not_uninitialized() {
    let source = "\
value=abcdef
printf '%s\\n' \"${value:offset}\" \"${value:1:$length}\"
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&["offset", "length"], &uninitialized);
    assert!(model.references().iter().any(|reference| {
        reference.kind == ReferenceKind::ParameterSliceArithmetic && reference.name == "offset"
    }));
    assert!(model.references().iter().any(|reference| {
        reference.kind == ReferenceKind::ParameterSliceArithmetic && reference.name == "length"
    }));
}

#[test]
fn defaulting_parameter_operand_references_are_marked_for_sc2154_suppression() {
    let source = "\
printf '%s\\n' \
  \"${missing_default:-$fallback_name}\" \
  \"${missing_assign:=$seed_name}\" \
  \"${missing_replace:+$replacement_name}\" \
  \"${missing_error:?$hint_name}\"
";
    let model = model(source);
    let suppressed = model
        .references()
        .iter()
        .filter(|reference| model.is_defaulting_parameter_operand_reference(reference.id))
        .map(|reference| reference.name.as_str())
        .collect::<Vec<_>>();

    assert_eq!(
        suppressed,
        vec![
            "fallback_name",
            "seed_name",
            "replacement_name",
            "hint_name",
        ]
    );
}

#[test]
fn guarded_or_defaulting_reference_offsets_are_grouped_by_name() {
    let source = "\
printf '%s\\n' \
  \"${missing_default:-$fallback_name}\" \
  \"${missing_assign:=$seed_name}\" \
  \"${missing_replace:+$seed_name}\" \
  \"${missing_error:?$hint_name}\" \
  \"${missing_error:?$hint_name}\"
";
    let model = model(source);
    let mut expected = FxHashMap::<String, Vec<usize>>::default();

    for reference in model.references() {
        if model.is_guarded_parameter_reference(reference.id)
            || model.is_defaulting_parameter_operand_reference(reference.id)
        {
            expected
                .entry(reference.name.as_str().to_owned())
                .or_default()
                .push(reference.span.start.offset());
        }
    }

    for offsets in expected.values_mut() {
        offsets.sort_unstable();
        offsets.dedup();
    }

    let actual = model
        .guarded_or_defaulting_reference_offsets_by_name()
        .iter()
        .map(|(name, offsets)| (name.as_str().to_owned(), offsets.to_vec()))
        .collect::<FxHashMap<_, _>>();

    assert_eq!(actual, expected);
}

#[test]
fn function_positional_reference_summary_respects_local_transient_resets() {
    let source = "\
greet() {
  (
    set -- inner
    printf '%s\\n' \"$1\"
  )
}
relay() {
  printf '%s\\n' \"$@\"
}
";
    let model = model(source);

    let greet_use_offset = span_for_nth(source, "printf '%s\\n' \"$1\"", 0)
        .start
        .offset();
    let relay_use_offset = span_for_nth(source, "printf '%s\\n' \"$@\"", 0)
        .start
        .offset();
    let greet_scope = model
        .enclosing_function_scope(model.scope_at(greet_use_offset))
        .unwrap();
    let relay_scope = model
        .enclosing_function_scope(model.scope_at(relay_use_offset))
        .unwrap();

    let without_resets = model.function_positional_reference_summary(&FxHashMap::default());
    assert_eq!(
        without_resets
            .get(&greet_scope)
            .copied()
            .unwrap()
            .required_arg_count(),
        1
    );
    assert!(
        without_resets
            .get(&greet_scope)
            .copied()
            .unwrap()
            .uses_unprotected_positional_parameters()
    );
    assert_eq!(
        without_resets
            .get(&relay_scope)
            .copied()
            .unwrap()
            .required_arg_count(),
        0
    );
    assert!(
        without_resets
            .get(&relay_scope)
            .copied()
            .unwrap()
            .uses_unprotected_positional_parameters()
    );

    let transient_scope = model
        .innermost_transient_scope_within_function(model.scope_at(greet_use_offset))
        .unwrap();
    let reset_offset = span_for_nth(source, "set -- inner", 0).start.offset();
    let mut local_resets = FxHashMap::default();
    local_resets.insert(transient_scope, vec![reset_offset]);

    let with_resets = model.function_positional_reference_summary(&local_resets);
    assert!(!with_resets.contains_key(&greet_scope));
    assert_eq!(
        with_resets
            .get(&relay_scope)
            .copied()
            .unwrap()
            .required_arg_count(),
        0
    );
    assert!(
        with_resets
            .get(&relay_scope)
            .copied()
            .unwrap()
            .uses_unprotected_positional_parameters()
    );
}

#[test]
fn branch_initialized_names_stay_initialized_inside_command_substitutions() {
    let source = "\
if [ \"$1\" = h ]; then
  humanreadable=-h
else
  humanreadable=-m
fi
value=\"$(free ${humanreadable} | awk '{print $2}')\"
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&["humanreadable"], &uninitialized);
}

#[test]
fn assign_default_parameter_expansion_initializes_later_reads() {
    let source = "\
printf '%s\\n' \"${config_path:=/tmp/default}\"
printf '%s\\n' \"$config_path\" \"$still_missing\"
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&["config_path"], &uninitialized);
    assert_names_present(&["still_missing"], &uninitialized);

    let binding = model
        .bindings()
        .iter()
        .find(|binding| {
            binding.name == "config_path"
                && matches!(binding.kind, BindingKind::ParameterDefaultAssignment)
        })
        .unwrap();
    assert_eq!(binding.span.slice(source), "${config_path:=/tmp/default}");
}

#[test]
fn parameter_guard_flow_initializes_later_c006_reads() {
    let source = "\
printf '%s\\n' \"${defaulted:-fallback}\"
printf '%s\\n' \"${assigned:=fallback}\"
printf '%s\\n' \"${required:?missing}\"
printf '%s\\n' \"${replacement:+alt}\"
printf '%s\\n' \"$defaulted\" \"$assigned\" \"$required\" \"$replacement\" \"$still_missing\"
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(
        &["defaulted", "assigned", "required", "replacement"],
        &uninitialized,
    );
    assert_names_present(&["still_missing"], &uninitialized);
}

#[test]
fn parameter_guard_flow_respects_same_command_order() {
    let source = "\
printf '%s\\n' \
  \"${same_default:-fallback}\" \"$same_default\" \
  \"${same_assigned:=fallback}\" \"$same_assigned\" \
  \"${same_required:?missing}\" \"$same_required\" \
  \"${same_replacement:+alt}\" \"$same_replacement\" \
  \"$before_default\" \"${before_default:-fallback}\" \
  \"$still_missing\"
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(
        &[
            "same_default",
            "same_assigned",
            "same_required",
            "same_replacement",
        ],
        &uninitialized,
    );
    assert_names_present(&["before_default", "still_missing"], &uninitialized);
}

#[test]
fn parameter_guard_flow_does_not_escape_conditional_operands() {
    let source = "\
printf '%s\\n' \"${outer:+${nested_default:-fallback}}\" \"$outer\" \"$nested_default\"
printf '%s\\n' \"${other:+${nested_replacement:+alt}}\" \"$other\" \"$nested_replacement\"
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&["outer", "other"], &uninitialized);
    assert_names_present(&["nested_default", "nested_replacement"], &uninitialized);
}

#[test]
fn parameter_guard_flow_does_not_escape_short_circuit_conditionals() {
    let source = "\
flag=
[[ ${left_guard:-fallback} && $flag ]]
printf '%s\\n' \"$left_guard\"
[[ $flag && ${right_and:-fallback} ]]
printf '%s\\n' \"$right_and\"
flag=1
[[ $flag || ${right_or:+alt} ]]
printf '%s\\n' \"$right_or\"
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&["left_guard"], &uninitialized);
    assert_names_present(&["right_and", "right_or"], &uninitialized);
}

#[test]
fn non_assigning_parameter_guard_flow_does_not_create_bindings() {
    let source = "\
: \"${defaulted:-fallback}\" \"${replacement:+alt}\"
";
    let model = model(source);

    assert!(model.bindings_for(&Name::from("defaulted")).is_empty());
    assert!(model.bindings_for(&Name::from("replacement")).is_empty());
    assert!(
        model
            .analysis()
            .summarize_scope_provided_bindings(ScopeId(0))
            .is_empty()
    );
}

#[test]
fn parameter_reference_spans_exclude_escaped_quotes_in_double_quoted_strings() {
    let source = "\
#!/bin/bash
rvm_info=\"
  uname: \\\"${_system_info}\\\"
\"
";
    let model = model(source);
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "_system_info")
        .unwrap();

    assert_eq!(reference.span.start.line(), 3);
    assert_eq!(reference.span.start.column(), 12);
    assert_eq!(reference.span.slice(source), "${_system_info}");
}

#[test]
fn parameter_reference_spans_recover_after_escaped_quotes_and_tabs_in_assignments() {
    let source = "\
#!/bin/bash
physmemtotal=\"${physmemtotal//,/.}\"
payload=\"{
\t\\\"client_id\\\": \\\"${uuidinstance}\\\",
\t\\\"events\\\": [
\t\t{
\t\t\\\"name\\\": \\\"LinuxGSM\\\",
\t\t\\\"params\\\": {
\t\t\t\\\"cpuusedmhzroundup\\\": \\\"${cpuusedmhzroundup}\\\",
\t\t\t\\\"diskused\\\": \\\"${serverfilesdu}\\\",
\t\t\t}
\t\t}
\t]
}\"
";
    let model = model(source);
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "serverfilesdu")
        .unwrap();

    assert_eq!(reference.span.start.line(), 10);
    assert_eq!(reference.span.start.column(), 20);
    assert_eq!(reference.span.slice(source), "${serverfilesdu}");
}

#[test]
fn unbraced_parameter_reference_spans_recover_after_escaped_quotes() {
    let source = "\
#!/bin/bash
rvm_info=\"
  path:         \\\"$rvm_path\\\"
\"
addtimestamp=\"gawk '{ print strftime(\\\\\\\"[$logtimestampformat]\\\\\\\"), \\\\\\$0 }'\"
";
    let model = model(source);
    let rvm_path = model
        .references()
        .iter()
        .find(|reference| reference.name == "rvm_path")
        .unwrap();
    let logtimestampformat = model
        .references()
        .iter()
        .find(|reference| reference.name == "logtimestampformat")
        .unwrap();

    assert_eq!(rvm_path.span.slice(source), "$rvm_path");
    assert_eq!(logtimestampformat.span.slice(source), "$logtimestampformat");
}

#[test]
fn parameter_reference_spans_include_nested_parameter_operator_suffixes() {
    let source = "\
rvm_ruby_gem_home=\"${rvm_ruby_gem_home%%${rvm_gemset_separator:-\"@\"}*}\"
if [ \"${skiprdeps/${_lf}/}\" != \"${skiprdeps}\" ]; then
  :
fi
";
    let model = model(source);
    let rvm_gem_home = model
        .references()
        .iter()
        .find(|reference| reference.name == "rvm_ruby_gem_home")
        .unwrap();
    let skiprdeps = model
        .references()
        .iter()
        .find(|reference| reference.name == "skiprdeps")
        .unwrap();

    assert_eq!(
        rvm_gem_home.span.slice(source),
        "${rvm_ruby_gem_home%%${rvm_gemset_separator:-\"@\"}*}"
    );
    assert_eq!(rvm_gem_home.span.end.column(), 71);
    assert_eq!(skiprdeps.span.slice(source), "${skiprdeps/${_lf}/}");
    assert_eq!(skiprdeps.span.end.column(), 27);
}

#[test]
fn default_parameter_operand_reads_are_tracked() {
    let source = "\
repo_root=$(pwd)
cache_dir=${1:-\"$repo_root/.cache\"}
printf '%s\\n' \"$cache_dir\"
";
    let model = model_with_dialect(source, ShellDialect::Posix);
    let unused = reportable_unused_names(&model);

    assert!(
        !unused.contains(&Name::from("repo_root")),
        "unused bindings: {:?}",
        unused
    );

    let reference = model
        .references()
        .iter()
        .find(|reference| {
            reference.kind == ReferenceKind::Expansion && reference.name == "repo_root"
        })
        .unwrap();
    let binding = model.resolved_binding(reference.id).unwrap();
    assert_eq!(binding.name, "repo_root");
}

#[test]
fn self_referential_default_initializers_are_not_reported_unused() {
    let source = "\
#!/bin/sh
STATE=\"${STATE:-in_progress}\"
DESCRIPTION=\"${DESCRIPTION:-Deployment metadata updated}\"
";
    let model = model_with_dialect(source, ShellDialect::Posix);
    let unused = reportable_unused_names(&model);

    assert!(
        !unused.contains(&Name::from("STATE")),
        "unused bindings: {:?}",
        unused
    );
    assert!(
        !unused.contains(&Name::from("DESCRIPTION")),
        "unused bindings: {:?}",
        unused
    );
}

#[test]
fn detects_dead_code_after_exit() {
    let source = "exit 0\necho dead\n";
    let model = model(source);
    let analysis = model.analysis();
    let dead_code = analysis.dead_code();
    assert_eq!(dead_code.len(), 1);
    assert_eq!(
        dead_code[0].unreachable[0].slice(source).trim_end(),
        "echo dead"
    );
    assert_eq!(dead_code[0].cause.slice(source).trim_end(), "exit 0");
}

#[test]
fn zsh_always_cleanup_after_return_is_reachable() {
    let source = "\
#!/bin/zsh
{
  return
} always {
  printf '%s\\n' cleanup
}
printf '%s\\n' after
";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let unreachable = model
        .analysis()
        .dead_code()
        .iter()
        .flat_map(|entry| entry.unreachable.iter())
        .map(|span| span.slice(source).trim_end().to_owned())
        .collect::<Vec<_>>();

    assert!(
        !unreachable.contains(&"printf '%s\\n' cleanup".to_owned()),
        "unreachable spans: {unreachable:?}"
    );
    assert!(
        unreachable.contains(&"printf '%s\\n' after".to_owned()),
        "unreachable spans: {unreachable:?}"
    );
}

#[test]
fn loop_control_condition_keeps_unreachable_if_tree_causes() {
    let source = "\
while true; do
  if break; then
    printf '%s\\n' after_true
  else
    printf '%s\\n' after_false
  fi
  printf '%s\\n' after_if
done
";
    let model = model(source);
    let analysis = model.analysis();
    let dead_code = analysis.dead_code();
    let unreachable = dead_code
        .iter()
        .flat_map(|entry| entry.unreachable.iter())
        .map(|span| span.slice(source).trim_end().to_owned())
        .collect::<Vec<_>>();

    assert!(unreachable.contains(&"printf '%s\\n' after_true".to_owned()));
    assert!(unreachable.contains(&"printf '%s\\n' after_false".to_owned()));
    assert!(unreachable.contains(&"printf '%s\\n' after_if".to_owned()));
    assert!(
        dead_code
            .iter()
            .all(|entry| entry.cause_kind == UnreachableCauseKind::LoopControl),
        "dead code: {dead_code:?}"
    );
}

#[test]
fn compound_dead_code_reports_outermost_spans() {
    let source = "\
return
if true; then
  echo one
fi
printf '%s\\n' two
";
    let model = model(source);
    let analysis = model.analysis();
    let dead_code = analysis.dead_code();
    let unreachable = dead_code
        .iter()
        .flat_map(|entry| entry.unreachable.iter())
        .map(|span| span.slice(source).trim_end().to_owned())
        .collect::<Vec<_>>();

    assert!(unreachable.contains(&"if true; then\n  echo one\nfi".to_owned()));
    assert!(unreachable.contains(&"printf '%s\\n' two".to_owned()));
    assert!(!unreachable.contains(&"echo one".to_owned()));
}

#[test]
fn dead_code_after_script_terminating_function_calls_is_detected() {
    let source = "\
exit_script() {
  exit 0
}
main() {
  exit_script
  printf '%s\\n' never
}
";
    let model = model(source);
    let analysis = model.analysis();
    let unreachable = analysis
        .dead_code()
        .iter()
        .flat_map(|entry| entry.unreachable.iter())
        .map(|span| span.slice(source).trim_end().to_owned())
        .collect::<Vec<_>>();

    assert!(unreachable.contains(&"printf '%s\\n' never".to_owned()));
}

#[test]
fn resolved_function_calls_feed_dataflow_and_script_termination() {
    let source = "\
reader() {
  printf '%s\\n' \"$value\"
}
exit_script() {
  exit 0
}
main() {
  value=ok
  reader
  exit_script
  printf '%s\\n' never
}
main
";
    let model = model(source);
    let analysis = model.analysis();
    let unused = binding_names(&model, analysis.dataflow().unused_assignment_ids());
    let unreachable = analysis
        .dead_code()
        .iter()
        .flat_map(|entry| entry.unreachable.iter())
        .map(|span| span.slice(source).trim_end().to_owned())
        .collect::<Vec<_>>();

    assert!(
        !unused.iter().any(|name| name == "value"),
        "unused bindings: {unused:?}"
    );
    assert!(
        unreachable.contains(&"printf '%s\\n' never".to_owned()),
        "unreachable spans: {unreachable:?}"
    );
}

#[test]
fn condition_body_after_script_terminating_condition_is_dead() {
    let source = "\
if exit 0; then
  printf '%s\\n' never
fi
";
    let model = model(source);
    let unreachable = model
        .analysis()
        .dead_code()
        .iter()
        .flat_map(|entry| entry.unreachable.iter())
        .map(|span| span.slice(source).trim_end().to_owned())
        .collect::<Vec<_>>();

    assert!(
        unreachable.contains(&"printf '%s\\n' never".to_owned()),
        "unreachable spans: {unreachable:?}"
    );
}

#[test]
fn case_return_paths_keep_helper_from_being_script_terminating() {
    let source = "\
die() {
  exit 1
}
helper() {
  case \"$1\" in
    ok)
      return
      ;;
  esac
  die
}
main() {
  helper ok
  printf '%s\\n' still_reachable
}
";
    let model = model(source);

    assert!(
        model.analysis().dead_code().is_empty(),
        "dead code: {:?}",
        model.analysis().dead_code()
    );
}

#[test]
fn loop_return_paths_keep_helper_from_being_script_terminating() {
    let source = "\
die() {
  exit 1
}
helper() {
  for item in 1; do
    return
  done
  die
}
main() {
  helper
  printf '%s\\n' still_reachable
}
";
    let model = model(source);

    assert!(
        model.analysis().dead_code().is_empty(),
        "dead code: {:?}",
        model.analysis().dead_code()
    );
}

#[test]
fn script_terminating_calls_use_rewritten_statement_spans() {
    let sources = [
        "\
exit_script() {
  exit 0
}
main() {
  exit_script >/dev/null
  printf '%s\\n' never
}
",
        "\
exit_script() {
  exit 0
}
main() {
  ! exit_script
  printf '%s\\n' never
}
",
    ];

    for source in sources {
        let model = model(source);
        let analysis = model.analysis();
        let unreachable = analysis
            .dead_code()
            .iter()
            .flat_map(|entry| entry.unreachable.iter())
            .map(|span| span.slice(source).trim_end().to_owned())
            .collect::<Vec<_>>();

        assert!(
            unreachable.contains(&"printf '%s\\n' never".to_owned()),
            "unreachable spans: {unreachable:?}"
        );
    }
}

#[test]
fn sourceable_file_return_keeps_helper_exit_calls_non_terminating() {
    let source = "\
[ -n \"$loaded\" ] && return
loaded=1
exit_script() {
  exit 0
}
main() {
  exit_script
  printf '%s\\n' still_reachable
}
";
    let model = model(source);

    assert!(
        model.analysis().dead_code().is_empty(),
        "dead code: {:?}",
        model.analysis().dead_code()
    );
}

#[test]
fn sourceable_file_return_before_helper_keeps_helper_exit_calls_non_terminating() {
    let source = "\
already_loaded() {
  :
}
[ -n \"$loaded\" ] && return
loaded=1
exit_script() {
  exit 0
}
main() {
  exit_script
  printf '%s\\n' still_reachable
}
";
    let model = model(source);

    assert!(
        model.analysis().dead_code().is_empty(),
        "dead code: {:?}",
        model.analysis().dead_code()
    );
}

#[test]
fn sourceable_file_return_after_helper_keeps_helper_exit_calls_terminating() {
    let source = "\
main() {
  exit_script
  printf '%s\\n' never
}
{
  exit_script() {
    exit 0
  }
  return
}
";
    let model = model(source);
    let unreachable = model
        .analysis()
        .dead_code()
        .iter()
        .flat_map(|dead_code| dead_code.unreachable.iter())
        .map(|span| span.slice(source).to_owned())
        .collect::<Vec<_>>();

    assert!(
        unreachable.contains(&"printf '%s\\n' never\n".to_owned()),
        "unreachable spans: {unreachable:?}"
    );
}

#[test]
fn brace_group_function_definitions_can_make_later_calls_terminating() {
    let source = "\
{
  exit_script() {
    exit 0
  }
}
main() {
  exit_script
  printf '%s\\n' never
}
";
    let model = model(source);
    let analysis = model.analysis();
    let unreachable = analysis
        .dead_code()
        .iter()
        .flat_map(|entry| entry.unreachable.iter())
        .map(|span| span.slice(source).trim_end().to_owned())
        .collect::<Vec<_>>();

    assert!(unreachable.contains(&"printf '%s\\n' never".to_owned()));
}

#[test]
fn later_parent_scope_function_definitions_can_terminate_later_runtime_calls() {
    let source = "\
main() {
  exit_script
  printf '%s\\n' never
}
exit_script() {
  exit 0
}
main
";
    let model = model(source);
    let analysis = model.analysis();
    let unreachable = analysis
        .dead_code()
        .iter()
        .flat_map(|entry| entry.unreachable.iter())
        .map(|span| span.slice(source).trim_end().to_owned())
        .collect::<Vec<_>>();

    assert!(unreachable.contains(&"printf '%s\\n' never".to_owned()));
}

#[test]
fn later_function_definitions_do_not_make_earlier_calls_terminating() {
    let source = "\
main() {
  exit_script
  printf '%s\\n' still_reachable
}
main
exit_script() {
  exit 0
}
";
    let model = model(source);

    assert!(
        model.analysis().dead_code().is_empty(),
        "dead code: {:?}",
        model.analysis().dead_code()
    );
}

#[test]
fn transitive_calls_before_parent_definitions_keep_later_code_reachable() {
    let source = "\
main() {
  helper
}
helper() {
  inner
}
inner() {
  exit_script
  printf '%s\\n' maybe
}
if should_run; then
  main
fi
exit_script() {
  exit 0
}
main
";
    let model = model(source);

    assert!(
        model.analysis().dead_code().is_empty(),
        "dead code: {:?}",
        model.analysis().dead_code()
    );
}

#[test]
fn top_level_parent_call_before_nested_definition_keeps_later_code_reachable() {
    let source = "\
outer() {
  inner() {
    helper
    printf '%s\\n' maybe
  }
  inner
  helper() {
    exit 0
  }
}
outer
";
    let model = model(source);

    assert!(
        model.analysis().dead_code().is_empty(),
        "dead code: {:?}",
        model.analysis().dead_code()
    );
}

#[test]
fn nested_calls_after_parent_definition_can_use_script_terminating_helpers() {
    let source = "\
outer() {
  inner() {
    helper
    printf '%s\\n' never
  }
  helper() {
    exit 0
  }
  inner
}
outer
";
    let model = model(source);
    let unreachable = model
        .analysis()
        .dead_code()
        .iter()
        .flat_map(|entry| entry.unreachable.iter())
        .map(|span| span.slice(source).trim_end().to_owned())
        .collect::<Vec<_>>();

    assert!(
        unreachable.contains(&"printf '%s\\n' never".to_owned()),
        "unreachable spans: {unreachable:?}"
    );
}

#[test]
fn later_redefinitions_do_not_fall_back_to_stale_terminating_helpers() {
    let source = "\
exit_script() {
  exit 0
}
main() {
  exit_script
  printf '%s\\n' maybe
}
if should_run; then
  main
fi
exit_script() {
  :
}
main
";
    let model = model(source);

    assert!(
        model.analysis().dead_code().is_empty(),
        "dead code: {:?}",
        model.analysis().dead_code()
    );
}

#[test]
fn conditional_function_definitions_do_not_make_calls_terminating() {
    let source = "\
if false; then
  exit_script() {
    exit 0
  }
fi
exit_script
printf '%s\\n' still_reachable
";
    let model = model(source);

    assert!(
        model.analysis().dead_code().is_empty(),
        "dead code: {:?}",
        model.analysis().dead_code()
    );
}

#[test]
fn conditional_exit_keeps_or_fallback_reachable() {
    let source = "run && exit 0 || echo fallback\n";
    let model = model(source);

    assert!(
        model.analysis().dead_code().is_empty(),
        "dead code: {:?}",
        model.analysis().dead_code()
    );
}

#[test]
fn deferred_function_bodies_resolve_later_file_scope_bindings() {
    let source = "f() { echo $X; }\nX=1\nf\n";
    let model = model(source);

    let reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "X")
        .unwrap();
    let binding = model.resolved_binding(reference.id).unwrap();
    assert_eq!(binding.span.slice(source), "X");
}

#[test]
fn deferred_non_brace_function_bodies_resolve_later_file_scope_bindings() {
    let source = "f() if true; then echo $X; fi\nX=1\nf\n";
    let model = model(source);

    let reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "X")
        .unwrap();
    let binding = model.resolved_binding(reference.id).unwrap();
    assert_eq!(binding.span.slice(source), "X");
}

#[test]
fn top_level_reads_remain_source_order_sensitive() {
    let source = "echo $X\nX=1\n";
    let model = model(source);

    let reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "X")
        .unwrap();
    assert!(model.resolved_binding(reference.id).is_none());
    assert_eq!(model.unresolved_references(), &[reference.id]);
}

#[test]
fn common_runtime_vars_are_not_marked_uninitialized_in_bash_and_sh_scripts() {
    let names = [
        "IFS",
        "USER",
        "HOME",
        "SHELL",
        "PWD",
        "TERM",
        "PATH",
        "CDPATH",
        "LANG",
        "LC_ALL",
        "LC_TIME",
        "SUDO_USER",
        "DOAS_USER",
    ];

    for shebang in ["#!/bin/bash", "#!/bin/sh"] {
        let source = common_runtime_source(shebang);
        let model = model(&source);
        let unresolved = unresolved_names(&model);
        let uninitialized = uninitialized_names(&model);

        assert_names_absent(&names, &unresolved);
        assert_names_absent(&names, &uninitialized);
    }
}

#[test]
fn bash_runtime_vars_are_not_marked_uninitialized_in_bash_scripts() {
    let source = bash_runtime_source("#!/bin/bash");
    let model = model(&source);
    let names = [
        "LINENO",
        "FUNCNAME",
        "BASH_SOURCE",
        "BASH_LINENO",
        "RANDOM",
        "BASH_REMATCH",
        "READLINE_LINE",
        "BASH_VERSION",
        "BASH_VERSINFO",
        "OSTYPE",
        "HISTCONTROL",
        "HISTSIZE",
    ];

    let unresolved = unresolved_names(&model);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&names, &unresolved);
    assert_names_absent(&names, &uninitialized);
}

#[test]
fn env_split_bash_shebang_enables_bash_runtime_vars() {
    let source = bash_runtime_source("#!/usr/bin/env -S bash -e");
    let model = model(&source);
    let names = [
        "LINENO",
        "FUNCNAME",
        "BASH_SOURCE",
        "BASH_LINENO",
        "RANDOM",
        "BASH_REMATCH",
        "READLINE_LINE",
        "BASH_VERSION",
        "BASH_VERSINFO",
        "OSTYPE",
        "HISTCONTROL",
        "HISTSIZE",
    ];

    let unresolved = unresolved_names(&model);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&names, &unresolved);
    assert_names_absent(&names, &uninitialized);
}

#[test]
fn inferred_profile_honors_env_split_shebang() {
    assert_eq!(
        infer_parse_dialect_from_source("#!/usr/bin/env -S sh -e\n:\n", None),
        ShellDialect::Posix
    );
    assert_eq!(
        infer_parse_dialect_from_source("#!/usr/bin/env -S zsh -f\nprint ok\n", None),
        ShellDialect::Zsh
    );
}

#[test]
fn bash_runtime_array_references_are_classified() {
    let source = "#!/bin/bash\nprintf '%s\\n' \"$BASH_SOURCE\" \"$FUNCNAME\" \"$RANDOM\"\n";
    let model = model(source);

    for name in ["BASH_SOURCE", "FUNCNAME"] {
        let reference = model
            .references()
            .iter()
            .find(|reference| reference.name == name)
            .unwrap();
        assert!(model.reference_is_predefined_runtime_array(reference.id));
    }

    let random = model
        .references()
        .iter()
        .find(|reference| reference.name == "RANDOM")
        .unwrap();
    assert!(!model.reference_is_predefined_runtime_array(random.id));
}

#[test]
fn zsh_runtime_vars_are_not_marked_uninitialized_in_zsh_scripts() {
    let source = zsh_runtime_source("#!/bin/zsh");
    let model = model(&source);
    let names = [
        "options",
        "functions",
        "aliases",
        "commands",
        "parameters",
        "termcap",
        "terminfo",
        "path",
        "pipestatus",
        "funcstack",
        "funcfiletrace",
        "funcsourcetrace",
        "psvar",
        "widgets",
        "zsh_eval_context",
        "module_path",
        "manpath",
        "mailpath",
        "historywords",
        "jobdirs",
        "jobstates",
        "jobtexts",
        "signals",
        "nameddirs",
        "MATCH",
        "match",
        "MBEGIN",
        "mbegin",
        "MEND",
        "mend",
        "BUFFER",
        "LBUFFER",
        "RBUFFER",
        "CURSOR",
        "WIDGET",
        "KEYS",
        "NUMERIC",
        "POSTDISPLAY",
        "histchars",
        "region_highlight",
        "LINES",
        "COLUMNS",
        "ZSH_VERSION",
        "ZSH_NAME",
        "ZSH_PATCHLEVEL",
        "ZSH_SUBSHELL",
        "ZSH_ARGZERO",
    ];

    let unresolved = unresolved_names(&model);
    let uninitialized = uninitialized_names(&model);

    assert_names_absent(&names, &unresolved);
    assert_names_absent(&names, &uninitialized);
}

#[test]
fn zsh_runtime_array_references_are_classified() {
    let source = "\
#!/bin/zsh
printf '%s\\n' \"${path[1]}\" \"${options[xtrace]}\" \"${pipestatus[1]}\" \"${match[1]}\" \"${nameddirs[project]}\" \"$ZSH_VERSION\"
";
    let model = model(source);

    for name in ["path", "options", "pipestatus", "match", "nameddirs"] {
        let reference = model
            .references()
            .iter()
            .find(|reference| reference.name == name)
            .unwrap();
        assert!(
            model.reference_is_predefined_runtime_array(reference.id),
            "{name} should be classified as a zsh runtime array"
        );
    }

    let version = model
        .references()
        .iter()
        .find(|reference| reference.name == "ZSH_VERSION")
        .unwrap();
    assert!(!model.reference_is_predefined_runtime_array(version.id));
}

#[test]
fn zsh_existence_tests_do_not_register_fake_variable_reads() {
    let source = "\
#!/bin/zsh
if (( $+commands[git] )); then
  :
fi
if (( ${+functions[zdot_warn]} )); then
  :
fi
if (( $+ZINIT_CNORM )); then
  :
fi
if (( $+commands[$cmd] )); then
  :
fi
if (( ${+functions[$fn]} )); then
  :
fi
if (( $+arr[i+1] )); then
  :
fi
";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let unresolved = unresolved_names(&model);
    let uninitialized = uninitialized_names(&model);
    let unresolved_details = model
        .unresolved_references()
        .iter()
        .map(|id| {
            let reference = model.reference(*id);
            (reference.name.to_string(), reference.span.slice(source))
        })
        .collect::<Vec<_>>();

    assert_eq!(
        unresolved_details,
        vec![
            ("cmd".into(), "$cmd"),
            ("fn".into(), "$fn"),
            ("i".into(), "i")
        ]
    );
    assert_names_absent(
        &[
            "+commands",
            "git",
            "commands",
            "+functions",
            "zdot_warn",
            "functions",
            "+ZINIT_CNORM",
            "ZINIT_CNORM",
            "+cmd",
            "+fn",
            "+arr",
            "arr",
        ],
        &unresolved,
    );
    assert_names_absent(
        &[
            "+commands",
            "git",
            "commands",
            "+functions",
            "zdot_warn",
            "functions",
            "+ZINIT_CNORM",
            "ZINIT_CNORM",
            "+cmd",
            "+fn",
            "+arr",
            "arr",
        ],
        &uninitialized,
    );
    assert_names_present(&["cmd", "fn", "i"], &unresolved);
    assert_names_present(&["cmd", "fn", "i"], &uninitialized);
}

#[test]
fn bash_runtime_vars_remain_unresolved_in_non_bash_scripts() {
    let source = bash_runtime_source("#!/bin/sh");
    let model = model(&source);
    let names = [
        "LINENO",
        "FUNCNAME",
        "BASH_SOURCE",
        "BASH_LINENO",
        "RANDOM",
        "BASH_REMATCH",
        "READLINE_LINE",
        "BASH_VERSION",
        "BASH_VERSINFO",
        "OSTYPE",
        "HISTCONTROL",
        "HISTSIZE",
    ];

    let unresolved = unresolved_names(&model);
    let uninitialized = uninitialized_names(&model);

    assert_names_present(&names, &unresolved);
    assert_names_present(&names, &uninitialized);
}

#[test]
fn deferred_nested_function_bodies_resolve_later_outer_bindings() {
    let source = "\
outer() {
  inner() { echo $X; }
  X=1
  inner
}
outer
";
    let model = model(source);

    let reference = model
        .references()
        .iter()
        .find(|reference| reference.kind == ReferenceKind::Expansion && reference.name == "X")
        .unwrap();
    let binding = model.resolved_binding(reference.id).unwrap();
    assert_eq!(binding.span.slice(source).trim(), "X");
    assert!(matches!(
        model.scope_kind(binding.scope),
        ScopeKind::Function(function) if function.contains_name_str("outer")
    ));
}

#[test]
fn top_level_assignment_read_by_later_function_call_is_live() {
    let source = "\
show() { echo \"$flag\"; }
flag=1
show
";
    let model = model(source);

    let unused = reportable_unused_names(&model);
    assert!(unused.is_empty(), "unused: {:?}", unused);
}

#[test]
fn sourced_helper_reads_keep_top_level_assignment_live() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
flag=1
. ./helper.sh
",
    )
    .unwrap();
    fs::write(&helper, "echo \"$flag\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
}

#[test]
fn bash_source_file_suffix_reads_keep_top_level_assignment_live_transitively() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.bash");
    let loader = temp.path().join("loader.bash");
    let helper = temp.path().join("loader.bash__dep.bash");
    fs::write(
        &main,
        "\
#!/bin/bash
flag=1
source ./loader.bash
",
    )
    .unwrap();
    fs::write(
        &loader,
        "\
#!/bin/bash
source \"${BASH_SOURCE[0]}__dep.bash\"
",
    )
    .unwrap();
    fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
}

#[test]
fn bash_source_double_zero_suffix_reads_keep_top_level_assignment_live_transitively() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.bash");
    let loader = temp.path().join("loader.bash");
    let helper = temp.path().join("loader.bash__dep.bash");
    fs::write(
        &main,
        "\
#!/bin/bash
flag=1
source ./loader.bash
",
    )
    .unwrap();
    fs::write(
        &loader,
        "\
#!/bin/bash
source \"${BASH_SOURCE[00]}__dep.bash\"
",
    )
    .unwrap();
    fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
}

#[test]
fn bash_source_spaced_zero_suffix_reads_keep_top_level_assignment_live_transitively() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.bash");
    let loader = temp.path().join("loader.bash");
    let helper = temp.path().join("loader.bash__dep.bash");
    fs::write(
        &main,
        "\
#!/bin/bash
flag=1
source ./loader.bash
",
    )
    .unwrap();
    fs::write(
        &loader,
        "\
#!/bin/bash
source \"${BASH_SOURCE[ 0 ]}__dep.bash\"
",
    )
    .unwrap();
    fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(unused.is_empty(), "unused: {:?}", unused);
}

#[test]
fn bash_source_nonzero_suffix_does_not_keep_top_level_assignment_live_transitively() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.bash");
    let loader = temp.path().join("loader.bash");
    let helper = temp.path().join("loader.bash__dep.bash");
    fs::write(
        &main,
        "\
#!/bin/bash
flag=1
source ./loader.bash
",
    )
    .unwrap();
    fs::write(
        &loader,
        "\
#!/bin/bash
source \"${BASH_SOURCE[1]}__dep.bash\"
",
    )
    .unwrap();
    fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        !model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert_eq!(unused, vec!["flag"]);
}

#[test]
fn bash_source_dirname_reads_keep_top_level_assignment_live_transitively() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.bash");
    let loader = temp.path().join("loader.bash");
    let helper = temp.path().join("helper.bash");
    fs::write(
        &main,
        "\
#!/bin/bash
flag=1
source ./loader.bash
",
    )
    .unwrap();
    fs::write(
        &loader,
        "\
#!/bin/bash
source \"$(dirname \"${BASH_SOURCE[0]}\")/helper.bash\"
",
    )
    .unwrap();
    fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(unused.is_empty(), "unused: {:?}", unused);
}

#[test]
fn executed_helper_reads_keep_loop_variable_live() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
for queryip in 127.0.0.1; do
  helper.sh
done
",
    )
    .unwrap();
    fs::write(&helper, "printf '%s\\n' \"$queryip\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "queryip"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("queryip")),
        "unused: {:?}",
        unused
    );
}

#[test]
fn executed_helper_without_read_does_not_keep_unrelated_assignment_live() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
unused=1
helper.sh
",
    )
    .unwrap();
    fs::write(&helper, "printf '%s\\n' ok\n").unwrap();

    let model = model_at_path(&main);

    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("unused")),
        "unused: {:?}",
        unused
    );
}

#[test]
fn loader_function_source_reads_keep_top_level_assignment_live() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
load() { . \"$ROOT/$1\"; }
flag=1
load helper.sh
",
    )
    .unwrap();
    fs::write(&helper, "echo \"$flag\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(unused.is_empty(), "unused: {:?}", unused);
}

#[test]
fn wrapped_loader_function_source_reads_keep_top_level_assignment_live() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
load() { . \"$ROOT/$1\"; }
flag=1
noglob load helper.sh
",
    )
    .unwrap();
    fs::write(&helper, "echo \"$flag\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
}

#[test]
fn source_path_resolver_keeps_helper_reads_generic() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("resolved/helper.sh");
    fs::create_dir_all(helper.parent().unwrap()).unwrap();
    fs::write(
        &main,
        "\
#!/bin/sh
flag=1
./helper.sh
",
    )
    .unwrap();
    fs::write(&helper, "echo \"$flag\"\n").unwrap();

    let without_resolver = model_at_path(&main);
    let unused_without_resolver = reportable_unused_names(&without_resolver);
    assert!(
        unused_without_resolver.contains(&Name::from("flag")),
        "unused without resolver: {:?}",
        unused_without_resolver
    );

    let main_path = main.clone();
    let helper_path = helper.clone();
    let resolver = move |source_path: &Path, candidate: &str| {
        if source_path == main_path.as_path() && candidate == "./helper.sh" {
            vec![helper_path.clone()]
        } else {
            Vec::new()
        }
    };

    let with_resolver = model_at_path_with_resolver(&main, Some(&resolver));
    assert!(
        with_resolver
            .synthetic_reads
            .iter()
            .any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        with_resolver.synthetic_reads
    );
    let unused_with_resolver = reportable_unused_names(&with_resolver);
    assert!(
        !unused_with_resolver.contains(&Name::from("flag")),
        "unused with resolver: {:?}",
        unused_with_resolver
    );
}

#[test]
fn missing_literal_source_is_marked_unresolved() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    fs::write(&main, "#!/bin/sh\n. ./missing.sh\n").unwrap();

    let model = model_at_path(&main);

    assert_eq!(model.source_refs().len(), 1);
    assert_eq!(
        model.source_refs()[0].resolution,
        SourceRefResolution::Unresolved
    );
}

#[test]
fn resolved_literal_source_is_marked_resolved() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(&main, "#!/bin/sh\n. ./helper.sh\n").unwrap();
    fs::write(&helper, "echo helper\n").unwrap();

    let model = model_at_path(&main);

    assert_eq!(model.source_refs().len(), 1);
    assert_eq!(
        model.source_refs()[0].resolution,
        SourceRefResolution::Resolved
    );
}

#[test]
fn source_path_resolver_can_use_single_variable_static_tails() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("tests/main.sh");
    let helper = temp.path().join("scripts/rvm");
    fs::create_dir_all(main.parent().unwrap()).unwrap();
    fs::create_dir_all(helper.parent().unwrap()).unwrap();
    fs::write(
        &main,
        "\
#!/bin/sh
flag=1
source \"$rvm_path/scripts/rvm\"
",
    )
    .unwrap();
    fs::write(&helper, "echo \"$flag\"\n").unwrap();

    let without_resolver = model_at_path(&main);
    let unused_without_resolver = reportable_unused_names(&without_resolver);
    assert!(
        unused_without_resolver.contains(&Name::from("flag")),
        "unused without resolver: {:?}",
        unused_without_resolver
    );

    let main_path = main.clone();
    let helper_path = helper.clone();
    let resolver = move |source_path: &Path, candidate: &str| {
        if source_path == main_path.as_path() && candidate == "scripts/rvm" {
            vec![helper_path.clone()]
        } else {
            Vec::new()
        }
    };

    let with_resolver = model_at_path_with_resolver(&main, Some(&resolver));
    assert!(
        with_resolver
            .synthetic_reads
            .iter()
            .any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        with_resolver.synthetic_reads
    );
    let unused_with_resolver = reportable_unused_names(&with_resolver);
    assert!(
        !unused_with_resolver.contains(&Name::from("flag")),
        "unused with resolver: {:?}",
        unused_with_resolver
    );
}

#[test]
fn sourced_helper_exports_definite_imported_binding() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
. ./helper.sh
printf '%s\\n' \"$flag\"
",
    )
    .unwrap();
    fs::write(&helper, "flag=1\n").unwrap();

    let model = model_at_path(&main);

    let imported = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "flag" && binding.kind == BindingKind::Imported)
        .unwrap();
    assert!(
        !imported
            .attributes
            .contains(BindingAttributes::IMPORTED_POSSIBLE)
    );
    assert!(model.analysis().uninitialized_references().is_empty());
}

#[test]
fn sourced_helper_exports_possible_imported_binding() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
. ./helper.sh
printf '%s\\n' \"$flag\"
",
    )
    .unwrap();
    fs::write(
        &helper,
        "\
if cond; then
  flag=1
fi
",
    )
    .unwrap();

    let model = model_at_path(&main);
    let imported_is_possible = model
        .bindings()
        .iter()
        .find(|binding| binding.name == "flag" && binding.kind == BindingKind::Imported)
        .map(|binding| {
            binding
                .attributes
                .contains(BindingAttributes::IMPORTED_POSSIBLE)
        })
        .unwrap();
    let details = uninitialized_details(&model);
    assert!(imported_is_possible, "uninitialized: {:?}", details);
    assert_eq!(
        details,
        vec![("flag".to_owned(), UninitializedCertainty::Possible)]
    );
}

#[test]
fn local_shadowing_can_clear_imported_initialization_before_nested_command_substitutions() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/bash
. ./helper.sh
f() {
  local flag
  printf '%s\\n' \"$(
    printf '%s\\n' \"$flag\"
  )\"
}
f
",
    )
    .unwrap();
    fs::write(&helper, "flag=1\n").unwrap();

    let model = model_at_path(&main);
    let uninitialized = uninitialized_names(&model);

    assert_names_present(&["flag"], &uninitialized);
}

#[test]
fn scope_entry_loops_preserve_possible_uninitialized_names() {
    let source = "\
while command; do
  flag=1
done
printf '%s\\n' \"$flag\"
";
    let model = model(source);

    assert_eq!(
        uninitialized_details(&model),
        vec![("flag".to_owned(), UninitializedCertainty::Possible)]
    );
}

#[test]
fn sourced_helper_function_reads_do_not_keep_assignments_live_until_called() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
flag=1
. ./helper.sh
",
    )
    .unwrap();
    fs::write(
        &helper,
        "\
use_flag() {
  printf '%s\\n' \"$flag\"
}
",
    )
    .unwrap();

    let model = model_at_path(&main);
    let unused = reportable_unused_names(&model);
    assert!(unused.contains(&Name::from("flag")), "unused: {:?}", unused);
}

#[test]
fn quoted_heredoc_body_does_not_report_uninitialized_reads() {
    let source = "\
build=\"$(command cat <<\\END
printf '%s\\n' \"$workdir\"
END
)\"
";
    let model = model(source);
    assert!(model.analysis().uninitialized_references().is_empty());
}

#[test]
fn escaped_dollar_heredoc_body_does_not_report_uninitialized_reads() {
    let source = "\
#!/bin/sh
cat <<EOF
\\${devtype} \\${devnum}
EOF
";
    let model = model(source);
    assert!(model.analysis().uninitialized_references().is_empty());
}

#[test]
fn escaped_dollar_word_does_not_report_uninitialized_reads() {
    let source = "\
#!/bin/sh
printf '%s\\n' \"\\$workdir\"
";
    let model = model(source);
    assert!(model.analysis().uninitialized_references().is_empty());
}

#[test]
fn escaped_parameter_expansion_keeps_nested_default_reads() {
    let source = "\
#!/bin/sh
printf '%s\\n' \\${workdir:-$fallback} \\${other:-${inner}}
";
    let model = model(source);
    let unresolved = unresolved_names(&model);

    assert_names_absent(&["workdir", "other"], &unresolved);
    assert_names_present(&["fallback", "inner"], &unresolved);
}

#[test]
fn unquoted_heredoc_body_reports_live_uninitialized_reads() {
    let source = "\
archname=archive
cat <<EOF > \"$archname\"
#!/bin/sh
ORIG_UMASK=`umask`
if test \"$KEEP_UMASK\" = n; then
    umask 077
fi

CRCsum=\"$CRCsum\"
archdirname=\"$archdirname\"
EOF
";
    let model = model(source);
    let details = uninitialized_details(&model);

    assert!(details.iter().any(
        |(name, certainty)| name == "CRCsum" && *certainty == UninitializedCertainty::Definite
    ));
    assert!(
        details.iter().any(|(name, certainty)| name == "archdirname"
            && *certainty == UninitializedCertainty::Definite)
    );
}

#[test]
fn quoted_heredoc_source_text_does_not_keep_assignments_live() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.bash");
    fs::write(
        &main,
        "\
#!/bin/bash
outdir=/tmp
build=\"$(command cat <<\\END
. \\\"$outdir\\\"/build.info
END
)\"
",
    )
    .unwrap();

    let model = model_at_path(&main);
    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("outdir")),
        "unused: {:?}",
        unused
    );
}

#[test]
fn quoted_heredoc_body_stays_inert_with_source_closure_enabled() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.bash");
    fs::write(
        &main,
        "\
#!/bin/sh
build=\"$(command cat <<\\END
for formula in libiconv cmake git wget; do
  if command brew ls --version \"$formula\" >/dev/null; then
    command brew upgrade \"$formula\"
  else
    command brew install \"$formula\"
  fi
done
archflag=\"-march\"
nopltflag=\"-fno-plt\"
cflags=\"$archflag=$cpu $nopltflag\"
. \"$outdir\"/build.info
END
)\"
",
    )
    .unwrap();

    let model = model_at_path(&main);
    assert!(
        model.analysis().uninitialized_references().is_empty(),
        "uninitialized: {:?}",
        model.analysis().uninitialized_references()
    );
}

#[test]
fn posix_quoted_heredoc_body_stays_inert_with_source_closure_enabled() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
build=\"$(command cat <<\\END
for formula in libiconv cmake git wget; do
  if command brew ls --version \"$formula\" >/dev/null; then
    command brew upgrade \"$formula\"
  else
    command brew install \"$formula\"
  fi
done
archflag=\"-march\"
nopltflag=\"-fno-plt\"
cflags=\"$archflag=$cpu $nopltflag\"
. \"$outdir\"/build.info
END
)\"
",
    )
    .unwrap();

    let model = model_at_path_with_parse_dialect(&main, ShellDialect::Posix);
    assert!(
        model.analysis().uninitialized_references().is_empty(),
        "uninitialized: {:?}",
        model.analysis().uninitialized_references()
    );
}

#[test]
fn posix_second_quoted_heredoc_body_stays_inert_with_source_closure_enabled() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
usage=\"$(command cat <<\\END
Usage
END
)\"

build=\"$(command cat <<\\END
for formula in libiconv cmake git wget; do
  if command brew ls --version \"$formula\" >/dev/null; then
    command brew upgrade \"$formula\"
  else
    command brew install \"$formula\"
  fi
done
archflag=\"-march\"
nopltflag=\"-fno-plt\"
cflags=\"$archflag=$cpu $nopltflag\"
. \"$outdir\"/build.info
END
)\"
",
    )
    .unwrap();

    let model = model_at_path_with_parse_dialect(&main, ShellDialect::Posix);
    assert!(
        model.analysis().uninitialized_references().is_empty(),
        "uninitialized: {:?}",
        model.analysis().uninitialized_references()
    );
}

#[test]
fn quoted_heredoc_build_template_executed_later_stays_inert() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("build.info");
    fs::write(
        &main,
        "\
#!/bin/sh
usage=\"$(command cat <<\\END
Usage
END
)\"

build=\"$(command cat <<\\END
outdir=\"$(command pwd)\"
workdir=\"${TMPDIR:-/tmp}/gitstatus-build.tmp.$$\"\n\
for formula in libiconv cmake git wget; do
  if command brew ls --version \"$formula\" >/dev/null 2>&1; then
    command brew upgrade \"$formula\"
  else
    command brew install \"$formula\"
  fi
done
archflag=\"-march\"
nopltflag=\"-fno-plt\"
cflags=\"$archflag=$cpu $nopltflag\"
. \"$outdir\"/build.info
END
)\"

eval \"$build\"
",
    )
    .unwrap();
    fs::write(&helper, "libgit2_version=1.0\n").unwrap();

    let model = model_at_path(&main);
    let references = model.analysis().uninitialized_references().to_vec();
    let names = references
        .iter()
        .map(|reference| model.reference(reference.reference).name.clone())
        .collect::<Vec<_>>();
    assert!(
        !names.iter().any(|name| {
            matches!(
                name.as_str(),
                "formula" | "archflag" | "nopltflag" | "outdir"
            )
        }),
        "uninitialized names: {names:?}"
    );
}

#[test]
fn eval_scan_keeps_apostrophe_inside_double_quotes_from_hiding_reads() {
    let source = "foo=1\neval \"echo \\\"it's \\$foo\\\"\"\n";
    let model = model(source);

    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("foo")),
        "unused bindings: {:?}",
        unused
    );
}

#[test]
fn eval_scan_keeps_quoted_hash_from_starting_comment() {
    let source = "foo=1\neval \"echo \\\"# \\$foo\\\"\"\n";
    let model = model(source);

    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("foo")),
        "unused bindings: {:?}",
        unused
    );
}

#[test]
fn eval_scan_treats_unquoted_hash_after_space_as_comment() {
    let source = "foo=1\neval \"echo # \\$foo\"\n";
    let model = model(source);

    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("foo")),
        "unused bindings: {:?}",
        unused
    );
}

#[test]
fn eval_scan_keeps_source_single_quoted_fragments_inert_for_c001() {
    let source = "foo=1\neval 'echo \"$foo\"'\n";
    let model = model(source);

    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("foo")),
        "unused bindings: {:?}",
        unused
    );
}

#[test]
fn eval_scan_treats_pid_prefix_as_special_parameter() {
    let source = "foo=1\neval \"echo $$foo\"\n";
    let model = model(source);

    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("foo")),
        "unused bindings: {:?}",
        unused
    );
}

#[test]
fn escaped_dollar_heredoc_body_stays_inert_with_source_closure_enabled() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
cat <<EOF > ./postinst
if [ \"\\$1\" = \"configure\" ]; then
  for ver in 1 current; do
    for x in rewriteSystem rewriteURI; do
      xmlcatalog --noout --add \\$x http://example.test/xsl/\\$ver
    done
  done
fi
EOF
",
    )
    .unwrap();

    let model = model_at_path(&main);
    assert!(
        model.analysis().uninitialized_references().is_empty(),
        "uninitialized: {:?}",
        model.analysis().uninitialized_references()
    );
}

#[test]
fn quoted_heredoc_case_arm_and_nested_same_name_heredoc_stay_inert() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
build=\"$(command cat <<\\END
case \"$gitstatus_kernel\" in
  linux)
    for formula in libiconv cmake git wget; do
      if command brew ls --version \"$formula\" >/dev/null; then
        command brew upgrade \"$formula\"
      else
        command brew install \"$formula\"
      fi
    done
  ;;
esac
command cat >&2 <<-END
\tSUCCESS
\tEND
END
)\"
",
    )
    .unwrap();

    let model = model_at_path(&main);
    assert!(
        model.analysis().uninitialized_references().is_empty(),
        "uninitialized: {:?}",
        model.analysis().uninitialized_references()
    );
}

#[test]
fn tab_stripped_escaped_dollar_heredoc_body_stays_inert() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
cat <<- EOF > ./postinst
\tif [ \"\\$1\" = \"configure\" ]; then
\t\tfor ver in 1 current; do
\t\t\tfor x in rewriteSystem rewriteURI; do
\t\t\t\txmlcatalog --noout --add \\$x http://example.test/xsl/\\$ver
\t\t\tdone
\t\tdone
\tfi
\tEOF
",
    )
    .unwrap();

    let model = model_at_path(&main);
    assert!(
        model.analysis().uninitialized_references().is_empty(),
        "uninitialized: {:?}",
        model.analysis().uninitialized_references()
    );
}

#[test]
fn posix_tab_stripped_escaped_dollar_heredoc_body_stays_inert() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
cat <<- EOF > ./postinst
\tif [ \"$TERMUX_PACKAGE_FORMAT\" = \"pacman\" ] || [ \"\\$1\" = \"configure\" ]; then
\t\tfor ver in $TERMUX_PKG_VERSION current; do
\t\t\tfor x in rewriteSystem rewriteURI; do
\t\t\t\txmlcatalog --noout --add \\$x http://docbook.sourceforge.net/release/xsl-ns/\\$ver \\
\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-$TERMUX_PKG_VERSION\" \\
\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
\t\t\tdone
\t\tdone
\tfi
\tEOF
",
    )
    .unwrap();

    let model = model_at_path_with_parse_dialect(&main, ShellDialect::Posix);
    let references = model.analysis().uninitialized_references().to_vec();
    let names = references
        .iter()
        .map(|reference| model.reference(reference.reference).name.clone())
        .collect::<Vec<_>>();
    assert!(
        !names
            .iter()
            .any(|name| matches!(name.as_str(), "x" | "ver")),
        "uninitialized names: {names:?}"
    );
}

#[test]
fn posix_docbook_wrapper_does_not_treat_escaped_placeholders_as_reads() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
termux_step_create_debscripts() {
\tcat <<- EOF > ./postinst
\t#!$TERMUX_PREFIX/bin/sh
\tif [ \"$TERMUX_PACKAGE_FORMAT\" = \"pacman\" ] || [ \"\\$1\" = \"configure\" ]; then
\t\tfor ver in $TERMUX_PKG_VERSION current; do
\t\t\tfor x in rewriteSystem rewriteURI; do
\t\t\t\txmlcatalog --noout --add \\$x http://cdn.docbook.org/release/xsl/\\$ver \\
\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-$TERMUX_PKG_VERSION\" \\
\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
\
\t\t\t\txmlcatalog --noout --add \\$x http://docbook.sourceforge.net/release/xsl-ns/\\$ver \\
\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-$TERMUX_PKG_VERSION\" \\
\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
\
\t\t\t\txmlcatalog --noout --add \\$x http://docbook.sourceforge.net/release/xsl/\\$ver \\
\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-${TERMUX_PKG_VERSION}-nons\" \\
\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
\t\t\tdone
\t\tdone
\tfi
\tEOF
}
",
    )
    .unwrap();

    let model = model_at_path_with_parse_dialect(&main, ShellDialect::Posix);
    let references = model.analysis().uninitialized_references().to_vec();
    let names = references
        .iter()
        .map(|reference| model.reference(reference.reference).name.clone())
        .collect::<Vec<_>>();
    assert!(
        !names
            .iter()
            .any(|name| matches!(name.as_str(), "x" | "ver")),
        "uninitialized names: {names:?}"
    );
}

#[test]
fn sourced_helper_function_reads_keep_assignments_live_when_called() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
flag=1
. ./helper.sh
use_flag
",
    )
    .unwrap();
    fs::write(
        &helper,
        "\
use_flag() {
  printf '%s\\n' \"$flag\"
}
",
    )
    .unwrap();

    let model = model_at_path(&main);
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("flag")),
        "unused: {:?}",
        unused
    );
}

#[test]
fn sourced_helper_function_exports_definite_imported_binding_when_called() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
. ./helper.sh
set_flag
printf '%s\\n' \"$flag\"
",
    )
    .unwrap();
    fs::write(
        &helper,
        "\
set_flag() {
  flag=1
}
",
    )
    .unwrap();

    let model = model_at_path(&main);
    assert!(model.analysis().uninitialized_references().is_empty());
}

#[test]
fn sourced_helper_function_exports_possible_imported_binding_when_called() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
. ./helper.sh
set_flag
printf '%s\\n' \"$flag\"
",
    )
    .unwrap();
    fs::write(
        &helper,
        "\
set_flag() {
  if cond; then
    flag=1
  fi
}
",
    )
    .unwrap();

    let model = model_at_path(&main);
    assert_eq!(
        uninitialized_details(&model),
        vec![("flag".to_owned(), UninitializedCertainty::Possible)]
    );
}

#[test]
fn unresolved_zsh_runtime_helpers_can_export_reply_bindings_when_called() {
    let temp = tempdir().unwrap();
    let module_dir = temp.path().join("zdot/modules/apt");
    fs::create_dir_all(&module_dir).unwrap();
    let main = module_dir.join("apt.zsh");
    fs::write(
        &main,
        "\
#!/bin/zsh
zdot_provides_tool_args ':zdot:apt' op eza
printf '%s\\n' \"${reply[@]}\"
",
    )
    .unwrap();

    let model = model_at_path(&main);
    assert!(model.analysis().uninitialized_references().is_empty());
}

#[test]
fn unresolved_zsh_reply_helpers_do_not_seed_non_runtime_paths() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("script.zsh");
    fs::write(
        &main,
        "\
#!/bin/zsh
zdot_provides_tool_args ':zdot:apt' op eza
printf '%s\\n' \"${reply[@]}\"
",
    )
    .unwrap();

    let model = model_at_path(&main);
    assert_eq!(
        uninitialized_details(&model),
        vec![("reply".to_owned(), UninitializedCertainty::Definite)]
    );
}

#[test]
fn layered_source_closure_imports_function_contracts_transitively() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let loader = temp.path().join("loader.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
. ./loader.sh
set_flag
printf '%s\\n' \"$flag\"
",
    )
    .unwrap();
    fs::write(&loader, ". ./helper.sh\n").unwrap();
    fs::write(
        &helper,
        "\
set_flag() {
  flag=1
}
",
    )
    .unwrap();

    let model = model_at_path(&main);
    assert!(model.analysis().uninitialized_references().is_empty());
}

#[test]
fn late_parent_scope_loader_calls_are_visible_from_nested_functions() {
    let source = "\
outer() {
  inner() { load_helper ./helper.sh; }
  load_helper() { . \"$1\"; }
  inner
}
";
    let model = model(source);
    let name = Name::from("load_helper");
    let call = &model.call_sites_for(&name)[0];
    assert!(
        model
            .analysis()
            .visible_function_binding_at_call(&name, call.name_span)
            .is_some()
    );
}

#[test]
fn executed_helper_does_not_import_bindings_back_to_the_caller() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
./helper.sh
printf '%s\\n' \"$flag\"
",
    )
    .unwrap();
    fs::write(&helper, "flag=1\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        model
            .bindings()
            .iter()
            .all(|binding| !(binding.name == "flag" && binding.kind == BindingKind::Imported))
    );
    assert_eq!(
        uninitialized_details(&model),
        vec![("flag".to_owned(), UninitializedCertainty::Definite)]
    );
}

#[test]
fn imported_bindings_do_not_resolve_reads_before_the_import_site() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let helper = temp.path().join("helper.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
printf '%s\\n' \"$flag\"
. ./helper.sh
",
    )
    .unwrap();
    fs::write(&helper, "flag=1\n").unwrap();

    let model = model_at_path(&main);
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "flag")
        .unwrap();

    assert!(model.resolved_binding(reference.id).is_none());
    assert_eq!(
        uninitialized_details(&model),
        vec![("flag".to_owned(), UninitializedCertainty::Definite)]
    );
}

#[test]
fn file_entry_contracts_seed_multiple_first_command_reads_as_imported_bindings() {
    let source = "printf '%s\\n' \"$pkgname\" \"$pkgver\" \"$wrksrc\"\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build_with_options(
        &output.file,
        source,
        &indexer,
        SemanticBuildOptions {
            file_entry_contract: Some(FileContract {
                required_reads: Vec::new(),
                provided_bindings: vec![
                    ProvidedBinding::new(
                        Name::from("pkgname"),
                        ProvidedBindingKind::Variable,
                        ContractCertainty::Definite,
                    ),
                    ProvidedBinding::new(
                        Name::from("pkgver"),
                        ProvidedBindingKind::Variable,
                        ContractCertainty::Definite,
                    ),
                    ProvidedBinding::new(
                        Name::from("wrksrc"),
                        ProvidedBindingKind::Variable,
                        ContractCertainty::Definite,
                    ),
                ],
                provided_functions: Vec::new(),
                externally_consumed_bindings: false,
                externally_consumed_binding_names: Vec::new(),
                externally_consumed_binding_prefixes: Vec::new(),
            }),
            ..SemanticBuildOptions::default()
        },
    );

    for name in ["pkgname", "pkgver", "wrksrc"] {
        let reference = model
            .references()
            .iter()
            .find(|reference| reference.name == name)
            .unwrap();
        let binding = model.resolved_binding(reference.id).unwrap();
        assert_eq!(binding.kind, BindingKind::Imported);
        assert_eq!(binding.name, name);
        assert!(
            binding
                .attributes
                .contains(BindingAttributes::IMPORTED_FILE_ENTRY)
        );
    }
    assert_eq!(
        uninitialized_details(&model),
        vec![
            ("pkgname".to_owned(), UninitializedCertainty::Definite),
            ("pkgver".to_owned(), UninitializedCertainty::Definite),
            ("wrksrc".to_owned(), UninitializedCertainty::Definite),
        ]
    );
}

#[test]
fn file_entry_contracts_seed_deferred_function_body_reads_as_imported_bindings() {
    let source = "\
build() {
  printf '%s\\n' \"$pkgname\" \"$pkgver\" \"$wrksrc\"
}
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build_with_options(
        &output.file,
        source,
        &indexer,
        SemanticBuildOptions {
            file_entry_contract: Some(FileContract {
                required_reads: Vec::new(),
                provided_bindings: vec![
                    ProvidedBinding::new(
                        Name::from("pkgname"),
                        ProvidedBindingKind::Variable,
                        ContractCertainty::Definite,
                    ),
                    ProvidedBinding::new(
                        Name::from("pkgver"),
                        ProvidedBindingKind::Variable,
                        ContractCertainty::Definite,
                    ),
                    ProvidedBinding::new(
                        Name::from("wrksrc"),
                        ProvidedBindingKind::Variable,
                        ContractCertainty::Definite,
                    ),
                ],
                provided_functions: Vec::new(),
                externally_consumed_bindings: false,
                externally_consumed_binding_names: Vec::new(),
                externally_consumed_binding_prefixes: Vec::new(),
            }),
            ..SemanticBuildOptions::default()
        },
    );

    for name in ["pkgname", "pkgver", "wrksrc"] {
        let reference = model
            .references()
            .iter()
            .find(|reference| reference.name == name && reference.kind == ReferenceKind::Expansion)
            .unwrap();
        let binding = model.resolved_binding(reference.id).unwrap();
        assert_eq!(binding.kind, BindingKind::Imported);
        assert_eq!(binding.name, name);
        assert!(
            binding
                .attributes
                .contains(BindingAttributes::IMPORTED_FILE_ENTRY)
        );
    }
    assert_eq!(
        uninitialized_details(&model),
        vec![
            ("pkgname".to_owned(), UninitializedCertainty::Definite),
            ("pkgver".to_owned(), UninitializedCertainty::Definite),
            ("wrksrc".to_owned(), UninitializedCertainty::Definite),
        ]
    );
}

#[test]
fn file_entry_contracts_seed_nested_function_regions_as_imported_bindings() {
    let source = "\
hook() {
  for f in ${pycompile_dirs}; do
    if [ \"${pkgname}\" = \"base-files\" ]; then
      echo \"python${pycompile_version}\"
    else
      printf '%s\\n' \"${pkgver}: ${f}\"
    fi
  done
}
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build_with_options(
        &output.file,
        source,
        &indexer,
        SemanticBuildOptions {
            file_entry_contract: Some(FileContract {
                required_reads: Vec::new(),
                provided_bindings: vec![
                    ProvidedBinding::new(
                        Name::from("pkgname"),
                        ProvidedBindingKind::Variable,
                        ContractCertainty::Definite,
                    ),
                    ProvidedBinding::new(
                        Name::from("pkgver"),
                        ProvidedBindingKind::Variable,
                        ContractCertainty::Definite,
                    ),
                    ProvidedBinding::new(
                        Name::from("pycompile_dirs"),
                        ProvidedBindingKind::Variable,
                        ContractCertainty::Definite,
                    ),
                    ProvidedBinding::new(
                        Name::from("pycompile_version"),
                        ProvidedBindingKind::Variable,
                        ContractCertainty::Definite,
                    ),
                ],
                provided_functions: Vec::new(),
                externally_consumed_bindings: false,
                externally_consumed_binding_names: Vec::new(),
                externally_consumed_binding_prefixes: Vec::new(),
            }),
            ..SemanticBuildOptions::default()
        },
    );

    for name in ["pkgname", "pkgver", "pycompile_dirs", "pycompile_version"] {
        let reference = model
            .references()
            .iter()
            .find(|reference| reference.name == name)
            .unwrap();
        let binding = model.resolved_binding(reference.id).unwrap();
        assert_eq!(binding.kind, BindingKind::Imported);
        assert_eq!(binding.name, name);
        assert!(
            binding
                .attributes
                .contains(BindingAttributes::IMPORTED_FILE_ENTRY)
        );
    }
    assert_eq!(
        uninitialized_details(&model),
        vec![
            (
                "pycompile_dirs".to_owned(),
                UninitializedCertainty::Definite
            ),
            ("pkgname".to_owned(), UninitializedCertainty::Definite),
            (
                "pycompile_version".to_owned(),
                UninitializedCertainty::Definite
            ),
            ("pkgver".to_owned(), UninitializedCertainty::Definite),
        ]
    );
}

#[test]
fn initialized_file_entry_bindings_suppress_uninitialized_reads() {
    let source = "printf '%s\\n' \"$theme_color\"\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build_with_options(
        &output.file,
        source,
        &indexer,
        SemanticBuildOptions {
            file_entry_contract: Some(FileContract {
                required_reads: Vec::new(),
                provided_bindings: vec![ProvidedBinding::new_file_entry_initialized(
                    Name::from("theme_color"),
                    ProvidedBindingKind::Variable,
                    ContractCertainty::Definite,
                )],
                provided_functions: Vec::new(),
                externally_consumed_bindings: false,
                externally_consumed_binding_names: Vec::new(),
                externally_consumed_binding_prefixes: Vec::new(),
            }),
            ..SemanticBuildOptions::default()
        },
    );

    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "theme_color")
        .unwrap();
    let binding = model.resolved_binding(reference.id).unwrap();
    assert!(
        binding
            .attributes
            .contains(BindingAttributes::IMPORTED_FILE_ENTRY_INITIALIZED)
    );
    assert!(uninitialized_names(&model).is_empty());
}

#[test]
fn file_entry_contracts_can_mark_assignments_as_caller_consumed() {
    let source = "published=1\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build_with_options(
        &output.file,
        source,
        &indexer,
        SemanticBuildOptions {
            file_entry_contract: Some(FileContract {
                required_reads: Vec::new(),
                provided_bindings: Vec::new(),
                provided_functions: Vec::new(),
                externally_consumed_bindings: true,
                externally_consumed_binding_names: Vec::new(),
                externally_consumed_binding_prefixes: Vec::new(),
            }),
            ..SemanticBuildOptions::default()
        },
    );

    let binding = binding_for_name(&model, "published");
    assert!(
        binding
            .attributes
            .contains(BindingAttributes::EXTERNALLY_CONSUMED)
    );
    assert!(model.analysis().unused_assignments().is_empty());
}

#[test]
fn file_entry_contracts_can_mark_exact_nonlocal_assignments_as_caller_consumed() {
    let source = "\
published=1
unpublished=1
helper() {
  local published=2
}
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let model = SemanticModel::build_with_options(
        &output.file,
        source,
        &indexer,
        SemanticBuildOptions {
            file_entry_contract: Some(FileContract {
                required_reads: Vec::new(),
                provided_bindings: Vec::new(),
                provided_functions: Vec::new(),
                externally_consumed_bindings: false,
                externally_consumed_binding_names: vec![Name::from("published")],
                externally_consumed_binding_prefixes: Vec::new(),
            }),
            ..SemanticBuildOptions::default()
        },
    );

    let published_bindings = model
        .bindings()
        .iter()
        .filter(|binding| binding.name == "published")
        .collect::<Vec<_>>();
    assert_eq!(published_bindings.len(), 2);
    assert!(
        published_bindings[0]
            .attributes
            .contains(BindingAttributes::EXTERNALLY_CONSUMED),
        "{published_bindings:?}"
    );
    assert!(
        !published_bindings[1]
            .attributes
            .contains(BindingAttributes::EXTERNALLY_CONSUMED),
        "{published_bindings:?}"
    );
    assert!(
        !binding_for_name(&model, "unpublished")
            .attributes
            .contains(BindingAttributes::EXTERNALLY_CONSUMED)
    );
    assert_eq!(
        binding_names(&model, model.analysis().unused_assignments()),
        vec!["unpublished", "published"]
    );
}

#[test]
fn cyclic_source_closure_does_not_invent_bindings() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let a = temp.path().join("a.sh");
    let b = temp.path().join("b.sh");
    fs::write(
        &main,
        "\
#!/bin/sh
. ./a.sh
printf '%s\\n' \"$flag\"
",
    )
    .unwrap();
    fs::write(&a, ". ./b.sh\n").unwrap();
    fs::write(&b, ". ./a.sh\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        model
            .bindings()
            .iter()
            .all(|binding| !(binding.name == "flag" && binding.kind == BindingKind::Imported))
    );
    assert_eq!(
        uninitialized_details(&model),
        vec![("flag".to_owned(), UninitializedCertainty::Definite)]
    );
}

#[test]
fn unsupported_bash_source_alias_fallback_does_not_keep_assignment_live() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.bash");
    let loader = temp.path().join("loader.bash");
    let helper = temp.path().join("helper.bash");
    fs::write(
        &main,
        "\
#!/bin/bash
flag=1
source ./loader.bash
",
    )
    .unwrap();
    fs::write(
        &loader,
        "\
#!/bin/bash
SELF=\"${BASH_SOURCE}\"
source \"$(dirname \"${SELF:-$0}\")/helper.bash\"
",
    )
    .unwrap();
    fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        !model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(unused.contains(&Name::from("flag")), "unused: {:?}", unused);
}

#[test]
fn escaped_bash_source_template_does_not_import_helper() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.bash");
    let helper = temp.path().join("helper.bash");
    fs::write(
        &main,
        "\
#!/bin/bash
flag=1
source \"\\$(dirname \\\"${BASH_SOURCE[0]}\\\")/helper.bash\"
",
    )
    .unwrap();
    fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        !model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(unused.contains(&Name::from("flag")), "unused: {:?}", unused);
}

#[test]
fn shellcheck_source_directive_overrides_bash_source_template() {
    let temp = tempdir().unwrap();
    let main = temp.path().join("main.bash");
    let loader = temp.path().join("loader.bash");
    let helper = temp.path().join("alt-helper.bash");
    fs::write(
        &main,
        "\
#!/bin/bash
flag=1
source ./loader.bash
",
    )
    .unwrap();
    fs::write(
        &loader,
        "\
#!/bin/bash
# shellcheck source=alt-helper.bash
source \"$(dirname \"${BASH_SOURCE[0]}\")/missing-helper.bash\"
",
    )
    .unwrap();
    fs::write(&helper, "#!/bin/bash\necho \"$flag\"\n").unwrap();

    let model = model_at_path(&main);

    assert!(
        model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    assert_eq!(
        model.source_refs()[0].resolution,
        SourceRefResolution::Resolved
    );
    let unused = reportable_unused_names(&model);
    assert!(unused.is_empty(), "unused: {:?}", unused);
}

#[test]
fn configured_zsh_plugin_entrypoint_reads_apply_after_file_assignments() {
    struct EntryResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for EntryResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::ExplicitFilesystem,
                kind: PluginRequestKind::Entrypoint,
                name: self.entrypoint.to_string_lossy().into_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.kind == PluginRequestKind::Entrypoint {
                PluginResolution {
                    entrypoints: vec![self.entrypoint.clone()],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let plugin = temp.path().join("plugin.zsh");
    fs::write(
        &main,
        "#!/bin/zsh\nif true; then\n  flag=1\nelse\n  flag=2\nfi\nlate_scope() {\n  local flag=3\n}\n",
    )
    .unwrap();
    fs::write(&plugin, "echo \"$flag\"\n").unwrap();

    let resolver = EntryResolver { entrypoint: plugin };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("flag")),
        "unused: {:?}",
        unused
    );
}

#[test]
fn plugin_request_contracts_mark_requesting_file_assignments_consumed() {
    struct ContractOnlyResolver;

    impl PluginResolver for ContractOnlyResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::OhMyZsh,
                kind: PluginRequestKind::Plugin,
                name: "tmux".to_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            _request: &PluginRequest,
        ) -> PluginResolution {
            PluginResolution {
                entrypoints: Vec::new(),
                file_entry_contracts: Vec::new(),
                requesting_file_contract: FileContract {
                    externally_consumed_binding_prefixes: vec![Name::from("ZSH_TMUX_")],
                    ..FileContract::default()
                },
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join(".zshrc");
    fs::write(
        &main,
        "ZSH_TMUX_AUTOQUIT=true\nZSH_TMUX_DEFAULT_SESSION_NAME=dev\nordinary=1\n",
    )
    .unwrap();

    let model = model_at_path_with_plugin_resolver(&main, &ContractOnlyResolver);

    assert_eq!(
        binding_names(&model, model.analysis().unused_assignments()),
        vec!["ordinary".to_owned()]
    );
}

#[test]
fn zsh_plugin_hook_callbacks_make_function_reads_required() {
    struct EntryResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for EntryResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::ExplicitFilesystem,
                kind: PluginRequestKind::Entrypoint,
                name: self.entrypoint.to_string_lossy().into_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.kind == PluginRequestKind::Entrypoint {
                PluginResolution {
                    entrypoints: vec![self.entrypoint.clone()],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let plugin = temp.path().join("plugin.zsh");
    fs::write(
        &main,
        "#!/bin/zsh\nZSH_AUTOSUGGEST_STRATEGY=(match_prev_cmd completion)\n",
    )
    .unwrap();
    fs::write(
        &plugin,
        "\
add-zsh-hook precmd _plugin_start
_plugin_start() {
  _plugin_fetch_suggestion
}
_plugin_fetch_suggestion() {
  strategies=(${=ZSH_AUTOSUGGEST_STRATEGY})
}
",
    )
    .unwrap();

    let resolver = EntryResolver { entrypoint: plugin };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "ZSH_AUTOSUGGEST_STRATEGY"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("ZSH_AUTOSUGGEST_STRATEGY")),
        "unused: {:?}",
        unused
    );
}

#[test]
fn prezto_zstyle_pmodule_loads_static_module_entrypoints() {
    struct PreztoResolver {
        root: PathBuf,
    }

    impl PluginResolver for PreztoResolver {
        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.framework == PluginFramework::Prezto
                && request.kind == PluginRequestKind::Plugin
            {
                PluginResolution {
                    entrypoints: vec![
                        self.root
                            .join("modules")
                            .join(&request.name)
                            .join("init.zsh"),
                    ],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("zpreztorc");
    let prezto_root = temp.path().join("prezto");
    let module = prezto_root.join("modules/editor/init.zsh");
    fs::create_dir_all(module.parent().unwrap()).unwrap();
    fs::write(
        &main,
        "\
#!/bin/zsh
EDITOR_STYLE=1
zstyle ':prezto:load' pmodule \
  'editor'
",
    )
    .unwrap();
    fs::write(&module, "print -r -- \"$EDITOR_STYLE\"\n").unwrap();

    let resolver = PreztoResolver { root: prezto_root };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "EDITOR_STYLE"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("EDITOR_STYLE")),
        "unused: {unused:?}"
    );
}

#[test]
fn prezto_pmodload_loads_static_transitive_module_entrypoints() {
    struct PreztoResolver {
        root: PathBuf,
    }

    impl PluginResolver for PreztoResolver {
        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.framework == PluginFramework::Prezto
                && request.kind == PluginRequestKind::Plugin
            {
                PluginResolution {
                    entrypoints: vec![
                        self.root
                            .join("modules")
                            .join(&request.name)
                            .join("init.zsh"),
                    ],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("zpreztorc");
    let prezto_root = temp.path().join("prezto");
    let outer = prezto_root.join("modules/outer/init.zsh");
    let inner = prezto_root.join("modules/inner/init.zsh");
    fs::create_dir_all(outer.parent().unwrap()).unwrap();
    fs::create_dir_all(inner.parent().unwrap()).unwrap();
    fs::write(
        &main,
        "\
#!/bin/zsh
INNER_STYLE=1
zstyle ':prezto:load' pmodule 'outer'
",
    )
    .unwrap();
    fs::write(&outer, "pmodload 'inner'\n").unwrap();
    fs::write(&inner, "print -r -- \"$INNER_STYLE\"\n").unwrap();

    let resolver = PreztoResolver { root: prezto_root };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "INNER_STYLE"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("INNER_STYLE")),
        "unused: {unused:?}"
    );
}

#[test]
fn prezto_dynamic_pmodule_styles_do_not_load_modules() {
    struct EmptyResolver;

    impl PluginResolver for EmptyResolver {
        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            _request: &PluginRequest,
        ) -> PluginResolution {
            panic!("dynamic Prezto module style should not resolve a plugin request");
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("zpreztorc");
    fs::write(
        &main,
        "\
#!/bin/zsh
DYNAMIC_ONLY=1
module=editor
zstyle ':prezto:load' pmodule \"$module\"
",
    )
    .unwrap();

    let model = model_at_path_with_plugin_resolver(&main, &EmptyResolver);
    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("DYNAMIC_ONLY")),
        "unused: {unused:?}"
    );
}

#[test]
fn prezto_zstyle_query_forms_do_not_load_target_variables_as_modules() {
    struct EmptyResolver;

    impl PluginResolver for EmptyResolver {
        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            _request: &PluginRequest,
        ) -> PluginResolution {
            panic!("Prezto zstyle query forms should not resolve plugin requests");
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("zpreztorc");
    fs::write(
        &main,
        "\
#!/bin/zsh
QUERY_ONLY=1
zstyle -a ':prezto:load' pmodule target_modules
zstyle -L -a ':prezto:load' pmodule
",
    )
    .unwrap();

    let model = model_at_path_with_plugin_resolver(&main, &EmptyResolver);
    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("QUERY_ONLY")),
        "unused: {unused:?}"
    );
}

#[test]
fn zdot_load_module_loads_static_module_entrypoints() {
    struct ZdotResolver {
        root: PathBuf,
    }

    impl PluginResolver for ZdotResolver {
        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.framework == PluginFramework::Zdot
                && request.kind == PluginRequestKind::Plugin
            {
                PluginResolution {
                    entrypoints: vec![
                        self.root
                            .join("modules")
                            .join(&request.name)
                            .join(format!("{}.zsh", request.name)),
                    ],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join(".zshrc");
    let zdot_root = temp.path().join("zdot");
    let module = zdot_root.join("modules/fzf/fzf.zsh");
    fs::create_dir_all(module.parent().unwrap()).unwrap();
    fs::write(
        &main,
        "\
#!/bin/zsh
FZF_STYLE=1
zdot_load_module fzf
",
    )
    .unwrap();
    fs::write(&module, "print -r -- \"$FZF_STYLE\"\n").unwrap();

    let resolver = ZdotResolver { root: zdot_root };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "FZF_STYLE"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("FZF_STYLE")),
        "unused: {unused:?}"
    );
}

#[test]
fn zdot_dynamic_module_loads_do_not_resolve() {
    struct EmptyResolver;

    impl PluginResolver for EmptyResolver {
        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            _request: &PluginRequest,
        ) -> PluginResolution {
            panic!("dynamic zdot module load should not resolve a plugin request");
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join(".zshrc");
    fs::write(
        &main,
        "\
#!/bin/zsh
DYNAMIC_ZDOT_ONLY=1
module=fzf
zdot_load_module \"$module\"
",
    )
    .unwrap();

    let model = model_at_path_with_plugin_resolver(&main, &EmptyResolver);
    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("DYNAMIC_ZDOT_ONLY")),
        "unused: {unused:?}"
    );
}

#[test]
fn zsh_plugin_eval_generated_callbacks_make_function_reads_required() {
    struct EntryResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for EntryResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::ExplicitFilesystem,
                kind: PluginRequestKind::Entrypoint,
                name: self.entrypoint.to_string_lossy().into_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.kind == PluginRequestKind::Entrypoint {
                PluginResolution {
                    entrypoints: vec![self.entrypoint.clone()],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let plugin = temp.path().join("plugin.zsh");
    fs::write(
        &main,
        "#!/bin/zsh\nZSH_AUTOSUGGEST_STRATEGY=(match_prev_cmd completion)\n",
    )
    .unwrap();
    fs::write(
        &plugin,
        "\
add-zsh-hook precmd _plugin_start
_plugin_start() {
  _plugin_bind_widget any modify
}
_plugin_bind_widget() {
  local widget=$1
  local action=$2
  eval '_plugin_widget_$action() { _plugin_$action \"$@\" }'
}
_plugin_modify() {
  _plugin_fetch
}
_plugin_fetch() {
  _plugin_fetch_suggestion
}
_plugin_fetch_suggestion() {
  strategies=(${=ZSH_AUTOSUGGEST_STRATEGY})
}
",
    )
    .unwrap();

    let resolver = EntryResolver { entrypoint: plugin };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "ZSH_AUTOSUGGEST_STRATEGY"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("ZSH_AUTOSUGGEST_STRATEGY")),
        "unused: {:?}",
        unused
    );
}

#[test]
fn zsh_plugin_generated_callback_inference_ignores_non_eval_text() {
    struct EntryResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for EntryResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::ExplicitFilesystem,
                kind: PluginRequestKind::Entrypoint,
                name: self.entrypoint.to_string_lossy().into_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.kind == PluginRequestKind::Entrypoint {
                PluginResolution {
                    entrypoints: vec![self.entrypoint.clone()],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let plugin = temp.path().join("plugin.zsh");
    fs::write(&main, "#!/bin/zsh\nPLUGIN_ONLY=1\n").unwrap();
    fs::write(
        &plugin,
        "\
add-zsh-hook precmd _plugin_start
_plugin_start() {
  _plugin_bind_widget any modify
}
_plugin_bind_widget() {
  local action=$2
  print \"_plugin_$action\"
}
_plugin_modify() {
  print \"$PLUGIN_ONLY\"
}
",
    )
    .unwrap();

    let resolver = EntryResolver { entrypoint: plugin };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        !model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "PLUGIN_ONLY"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("PLUGIN_ONLY")),
        "unused: {unused:?}"
    );
}

#[test]
fn zsh_plugin_generated_callback_inference_ignores_commented_eval_text() {
    struct EntryResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for EntryResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::ExplicitFilesystem,
                kind: PluginRequestKind::Entrypoint,
                name: self.entrypoint.to_string_lossy().into_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.kind == PluginRequestKind::Entrypoint {
                PluginResolution {
                    entrypoints: vec![self.entrypoint.clone()],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let plugin = temp.path().join("plugin.zsh");
    fs::write(&main, "#!/bin/zsh\nCOMMENT_ONLY=1\n").unwrap();
    fs::write(
        &plugin,
        "\
add-zsh-hook precmd _plugin_start
_plugin_start() {
  _plugin_bind_widget any modify
}
_plugin_bind_widget() {
  local action=$2
  # eval '_plugin_widget_$action() { _plugin_$action \"$@\" }'
}
_plugin_modify() {
  print \"$COMMENT_ONLY\"
}
",
    )
    .unwrap();

    let resolver = EntryResolver { entrypoint: plugin };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        !model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "COMMENT_ONLY"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("COMMENT_ONLY")),
        "unused: {unused:?}"
    );
}

#[test]
fn zsh_plugin_eval_generated_callbacks_handle_quoted_positional_aliases() {
    struct EntryResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for EntryResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::ExplicitFilesystem,
                kind: PluginRequestKind::Entrypoint,
                name: self.entrypoint.to_string_lossy().into_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.kind == PluginRequestKind::Entrypoint {
                PluginResolution {
                    entrypoints: vec![self.entrypoint.clone()],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let plugin = temp.path().join("plugin.zsh");
    fs::write(&main, "#!/bin/zsh\nQUOTED_ALIAS=1\n").unwrap();
    fs::write(
        &plugin,
        "\
add-zsh-hook precmd _plugin_start
_plugin_start() {
  _plugin_bind_widget any modify
}
_plugin_bind_widget() {
  local action=\"$2\"
  eval '_plugin_widget_$action() { _plugin_$action \"$@\" }'
}
_plugin_modify() {
  print \"$QUOTED_ALIAS\"
}
",
    )
    .unwrap();

    let resolver = EntryResolver { entrypoint: plugin };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "QUOTED_ALIAS"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("QUOTED_ALIAS")),
        "unused: {unused:?}"
    );
}

#[test]
fn zsh_plugin_deferred_file_scope_reads_ignore_declaration_names() {
    struct EntryResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for EntryResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::ExplicitFilesystem,
                kind: PluginRequestKind::Entrypoint,
                name: self.entrypoint.to_string_lossy().into_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.kind == PluginRequestKind::Entrypoint {
                PluginResolution {
                    entrypoints: vec![self.entrypoint.clone()],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let plugin = temp.path().join("plugin.zsh");
    fs::write(&main, "#!/bin/zsh\nPLUGIN_DECLARED=1\n").unwrap();
    fs::write(
        &plugin,
        "\
add-zsh-hook precmd _plugin_start
_plugin_start() {
  typeset -g PLUGIN_DECLARED
}
",
    )
    .unwrap();

    let resolver = EntryResolver { entrypoint: plugin };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        !model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "PLUGIN_DECLARED"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("PLUGIN_DECLARED")),
        "unused: {unused:?}"
    );
}

#[test]
fn zsh_plugin_hook_listing_does_not_make_callback_reads_required() {
    struct EntryResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for EntryResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::ExplicitFilesystem,
                kind: PluginRequestKind::Entrypoint,
                name: self.entrypoint.to_string_lossy().into_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.kind == PluginRequestKind::Entrypoint {
                PluginResolution {
                    entrypoints: vec![self.entrypoint.clone()],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let plugin = temp.path().join("plugin.zsh");
    fs::write(&main, "#!/bin/zsh\nLISTED_ONLY=1\n").unwrap();
    fs::write(
        &plugin,
        "\
add-zsh-hook -L precmd _plugin_listed
_plugin_listed() {
  print \"$LISTED_ONLY\"
}
",
    )
    .unwrap();

    let resolver = EntryResolver { entrypoint: plugin };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        !model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "LISTED_ONLY"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("LISTED_ONLY")),
        "unused: {unused:?}"
    );
}

#[test]
fn zsh_plugin_hook_registration_collects_later_callbacks() {
    struct EntryResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for EntryResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::ExplicitFilesystem,
                kind: PluginRequestKind::Entrypoint,
                name: self.entrypoint.to_string_lossy().into_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.kind == PluginRequestKind::Entrypoint {
                PluginResolution {
                    entrypoints: vec![self.entrypoint.clone()],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let plugin = temp.path().join("plugin.zsh");
    fs::write(&main, "#!/bin/zsh\nSECOND_CALLBACK=1\n").unwrap();
    fs::write(
        &plugin,
        "\
add-zsh-hook precmd _plugin_first _plugin_second
_plugin_first() {
  :
}
_plugin_second() {
  print \"$SECOND_CALLBACK\"
}
",
    )
    .unwrap();

    let resolver = EntryResolver { entrypoint: plugin };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "SECOND_CALLBACK"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("SECOND_CALLBACK")),
        "unused: {unused:?}"
    );
}

#[test]
fn zsh_plugin_deferred_callback_uses_latest_function_definition() {
    struct EntryResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for EntryResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::ExplicitFilesystem,
                kind: PluginRequestKind::Entrypoint,
                name: self.entrypoint.to_string_lossy().into_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.kind == PluginRequestKind::Entrypoint {
                PluginResolution {
                    entrypoints: vec![self.entrypoint.clone()],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let plugin = temp.path().join("plugin.zsh");
    fs::write(&main, "#!/bin/zsh\nOLD_CALLBACK=1\nNEW_CALLBACK=1\n").unwrap();
    fs::write(
        &plugin,
        "\
add-zsh-hook precmd _plugin_callback
_plugin_callback() {
  print \"$OLD_CALLBACK\"
}
_plugin_callback() {
  print \"$NEW_CALLBACK\"
}
",
    )
    .unwrap();

    let resolver = EntryResolver { entrypoint: plugin };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    let unused = reportable_unused_names(&model);
    assert!(
        unused.contains(&Name::from("OLD_CALLBACK")),
        "unused: {unused:?}"
    );
    assert!(
        !unused.contains(&Name::from("NEW_CALLBACK")),
        "unused: {unused:?}"
    );
}

#[test]
fn zsh_plugin_hook_registration_inside_called_setup_function_is_deferred_root() {
    struct EntryResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for EntryResolver {
        fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
            vec![PluginRequest {
                framework: PluginFramework::ExplicitFilesystem,
                kind: PluginRequestKind::Entrypoint,
                name: self.entrypoint.to_string_lossy().into_owned(),
                span: Span::new(),
                explicit: true,
                root_hint: None,
            }]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            request: &PluginRequest,
        ) -> PluginResolution {
            if request.kind == PluginRequestKind::Entrypoint {
                PluginResolution {
                    entrypoints: vec![self.entrypoint.clone()],
                    file_entry_contracts: Vec::new(),
                    requesting_file_contract: FileContract::default(),
                }
            } else {
                PluginResolution::default()
            }
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let plugin = temp.path().join("plugin.zsh");
    fs::write(&main, "#!/bin/zsh\nSETUP_CALLBACK=1\n").unwrap();
    fs::write(
        &plugin,
        "\
_plugin_setup() {
  add-zsh-hook precmd _plugin_callback
}
_plugin_setup
_plugin_callback() {
  print \"$SETUP_CALLBACK\"
}
",
    )
    .unwrap();

    let resolver = EntryResolver { entrypoint: plugin };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        model
            .synthetic_reads
            .iter()
            .any(|read| read.name == "SETUP_CALLBACK"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("SETUP_CALLBACK")),
        "unused: {unused:?}"
    );
}

#[test]
fn plugin_aware_zsh_source_resolution_imports_framework_entrypoint() {
    struct SourceResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for SourceResolver {
        fn resolve_source_path(&self, _source_path: &Path, candidate: &str) -> Vec<PathBuf> {
            if candidate.ends_with("/oh-my-zsh.sh") {
                vec![self.entrypoint.clone()]
            } else {
                Vec::new()
            }
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            _request: &PluginRequest,
        ) -> PluginResolution {
            PluginResolution::default()
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.zsh");
    let entrypoint = temp.path().join("oh-my-zsh.sh");
    fs::write(
        &main,
        "#!/bin/zsh\nZSH=/not-installed/.oh-my-zsh\nflag=1\nsource \"$ZSH/oh-my-zsh.sh\"\n",
    )
    .unwrap();
    fs::write(&entrypoint, "echo \"$flag\"\n").unwrap();

    let resolver = SourceResolver { entrypoint };
    let model = model_at_path_with_plugin_resolver(&main, &resolver);

    assert!(
        model.synthetic_reads.iter().any(|read| read.name == "flag"),
        "synthetic reads: {:?}",
        model.synthetic_reads
    );
    assert_eq!(
        model.source_refs()[0].resolution,
        SourceRefResolution::Resolved
    );
    assert!(model.source_refs()[0].explicitly_provided);
    let unused = reportable_unused_names(&model);
    assert!(
        !unused.contains(&Name::from("flag")),
        "unused: {:?}",
        unused
    );
}

#[test]
fn plugin_source_resolution_does_not_apply_to_non_zsh_sources() {
    struct SourceResolver {
        entrypoint: PathBuf,
    }

    impl PluginResolver for SourceResolver {
        fn resolve_source_path(&self, _source_path: &Path, _candidate: &str) -> Vec<PathBuf> {
            vec![self.entrypoint.clone()]
        }

        fn resolve_plugin_request(
            &self,
            _source_path: &Path,
            _request: &PluginRequest,
        ) -> PluginResolution {
            PluginResolution::default()
        }
    }

    let temp = tempdir().unwrap();
    let main = temp.path().join("main.sh");
    let entrypoint = temp.path().join("plugin.sh");
    fs::write(
        &main,
        "#!/bin/sh\nflag=1\nsource /opt/app/plugins/plugin.sh\n",
    )
    .unwrap();
    fs::write(&entrypoint, "echo \"$flag\"\n").unwrap();

    let resolver = SourceResolver { entrypoint };
    let source = fs::read_to_string(&main).unwrap();
    let output = Parser::with_dialect(&source, ShellDialect::Bash)
        .parse()
        .unwrap();
    let indexer = Indexer::new(&source, &output);
    let model = SemanticModel::build_with_options(
        &output.file,
        &source,
        &indexer,
        SemanticBuildOptions {
            source_path: Some(&main),
            plugin_resolver: Some(&resolver),
            shell_profile: Some(ShellProfile::native(shuck_parser::ShellDialect::Bash)),
            ..SemanticBuildOptions::default()
        },
    );

    assert_eq!(
        model.source_refs()[0].resolution,
        SourceRefResolution::Unresolved
    );
    assert!(!model.source_refs()[0].explicitly_provided);
}

#[test]
fn precise_unused_assignments_match_dataflow_for_source_closure_cases() {
    let temp = tempdir().unwrap();

    let sourced_main = temp.path().join("sourced-main.sh");
    let sourced_helper = temp.path().join("sourced-helper.sh");
    fs::write(
        &sourced_main,
        "\
#!/bin/sh
flag=1
. ./sourced-helper.sh
",
    )
    .unwrap();
    fs::write(&sourced_helper, "echo \"$flag\"\n").unwrap();

    let executed_main = temp.path().join("executed-main.sh");
    let executed_helper = temp.path().join("executed-helper.sh");
    fs::write(
        &executed_main,
        "\
#!/bin/sh
unused=1
executed-helper.sh
",
    )
    .unwrap();
    fs::write(&executed_helper, "printf '%s\\n' ok\n").unwrap();

    let sourced_model = model_at_path(&sourced_main);
    assert_unused_assignment_parity(&sourced_model);

    let executed_model = model_at_path(&executed_main);
    assert_unused_assignment_parity(&executed_model);
}

#[test]
fn non_arithmetic_subscript_reads_are_recorded_in_conditionals_and_declarations() {
    let source = "\
#!/bin/bash
[[ -v assoc[\"$key\"] ]]
declare -A map=([\"$other\"]=1)
";
    let model = model(source);
    let unresolved = unresolved_names(&model);

    assert_names_present(&["key", "other"], &unresolved);

    let conditional_reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "key")
        .expect("expected conditional subscript reference");
    assert_eq!(
        conditional_reference.kind,
        ReferenceKind::ConditionalOperand
    );

    let declaration_reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "other")
        .expect("expected declaration subscript reference");
    assert_eq!(declaration_reference.kind, ReferenceKind::Expansion);
}

#[test]
fn associative_subscript_literals_do_not_register_variable_reads() {
    let source = "\
#!/bin/bash
declare -A map
map[swift-cmark]=1
printf '%s\\n' \"${map[swift-cmark]}\" \"${map[$dynamic_key]}\"
";
    let model = model(source);
    let unresolved = unresolved_names(&model);

    assert_names_absent(&["swift", "cmark"], &unresolved);
    assert_names_present(&["dynamic_key"], &unresolved);
    assert!(
        model
            .bindings()
            .iter()
            .rev()
            .find(|binding| binding.name == "map")
            .is_some_and(|binding| binding.attributes.contains(BindingAttributes::ASSOC))
    );
}

#[test]
fn associative_arithmetic_subscript_literals_do_not_register_variable_reads() {
    let source = "\
#!/bin/bash
declare -A box
printf '%s\\n' \"$((box[m_width]))\" \"$((box[$dynamic_key]))\"
";
    let model = model(source);
    let unresolved = unresolved_names(&model);

    assert_names_absent(&["m_width"], &unresolved);
    assert_names_present(&["dynamic_key"], &unresolved);
}

#[test]
fn zsh_associative_key_literals_do_not_register_variable_reads() {
    let source = "\
#!/bin/zsh
typeset -A ZINIT ICE
ZINIT[ice-list]=x
ICE[ps-on-update]=x
functions[iterm2_precmd]=x
print -r -- ${functions[iterm2_precmd]}
";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let unresolved = unresolved_names(&model);

    assert_names_absent(
        &["ice", "list", "ps", "on", "update", "iterm2_precmd"],
        &unresolved,
    );
}

#[test]
fn whole_array_reassignment_preserves_visible_associative_attributes() {
    let source = "\
#!/bin/zsh
typeset -A ZINIT
ZINIT=( ${(kv)OTHER[@]} ${(kv)ZINIT[@]} )
ZINIT[bkp-autoload]=${functions[autoload]}
print -r -- ${ZINIT[bkp-autoload]}
";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let unresolved = unresolved_names(&model);

    assert_names_absent(&["bkp", "autoload"], &unresolved);
    assert!(
        model
            .bindings()
            .iter()
            .rev()
            .find(|binding| binding.name == "ZINIT")
            .is_some_and(|binding| binding.attributes.contains(BindingAttributes::ASSOC))
    );
}

#[test]
fn explicit_indexed_redeclaration_does_not_preserve_associative_attributes() {
    let source = "\
#!/bin/zsh
typeset -A opts
typeset -a opts=(alpha beta)
print -r -- ${opts[$idx]}
";
    let model = model_with_dialect(source, ShellDialect::Zsh);
    let unresolved = unresolved_names(&model);

    assert_names_present(&["idx"], &unresolved);
    assert!(
        model
            .bindings()
            .iter()
            .rev()
            .find(|binding| binding.name == "opts")
            .is_some_and(|binding| {
                binding.attributes.contains(BindingAttributes::ARRAY)
                    && !binding.attributes.contains(BindingAttributes::ASSOC)
            })
    );
}

#[test]
fn zsh_subscript_declarations_preserve_explicit_array_flags() {
    let source = "\
#!/bin/zsh
indexed=scalar
assoc=scalar
plain=scalar
typeset -a indexed[1]=x
typeset -A assoc[key]=x
typeset plain[1]=x
";
    let model = model_with_dialect(source, ShellDialect::Zsh);

    let indexed = model
        .bindings()
        .iter()
        .rev()
        .find(|binding| {
            binding.name == "indexed"
                && binding.kind == BindingKind::Declaration(DeclarationBuiltin::Typeset)
        })
        .expect("expected typed indexed declaration");
    assert!(indexed.attributes.contains(BindingAttributes::ARRAY));
    assert!(!indexed.attributes.contains(BindingAttributes::ASSOC));

    let assoc = model
        .bindings()
        .iter()
        .rev()
        .find(|binding| {
            binding.name == "assoc"
                && binding.kind == BindingKind::Declaration(DeclarationBuiltin::Typeset)
        })
        .expect("expected typed associative declaration");
    assert!(assoc.attributes.contains(BindingAttributes::ARRAY));
    assert!(assoc.attributes.contains(BindingAttributes::ASSOC));

    let plain = model
        .bindings()
        .iter()
        .rev()
        .find(|binding| {
            binding.name == "plain"
                && binding.kind == BindingKind::Declaration(DeclarationBuiltin::Typeset)
        })
        .expect("expected plain declaration");
    assert!(!plain.attributes.contains(BindingAttributes::ARRAY));
    assert!(!plain.attributes.contains(BindingAttributes::ASSOC));
}

#[test]
fn arithmetic_indexed_writes_preserve_associative_attributes() {
    let source = "\
#!/bin/bash
declare -A box
(( box[key] = 1 ))
printf '%s\\n' \"$((box[m_width]))\" \"$((box[$dynamic_key]))\"
";
    let model = model(source);
    let unresolved = unresolved_names(&model);

    assert_names_absent(&["key", "m_width"], &unresolved);
    assert_names_present(&["dynamic_key"], &unresolved);

    let arithmetic_binding = model
        .bindings()
        .iter()
        .rev()
        .find(|binding| binding.name == "box" && binding.kind == BindingKind::ArithmeticAssignment)
        .expect("expected arithmetic box binding");
    assert!(
        arithmetic_binding
            .attributes
            .contains(BindingAttributes::ARRAY)
    );
    assert!(
        arithmetic_binding
            .attributes
            .contains(BindingAttributes::ASSOC)
    );
}

#[test]
fn parameter_default_subscript_after_unset_does_not_inherit_associative_attributes() {
    let source = "\
#!/bin/bash
declare -A map
unset map
: \"${map[$key]:=}\"
";
    let model = model(source);

    let binding = model
        .bindings()
        .iter()
        .rev()
        .find(|binding| {
            binding.name == "map" && binding.kind == BindingKind::ParameterDefaultAssignment
        })
        .expect("expected parameter-default map binding");
    assert!(binding.attributes.contains(BindingAttributes::ARRAY));
    assert!(!binding.attributes.contains(BindingAttributes::ASSOC));
}

#[test]
fn parameter_default_subscript_after_function_unset_does_not_inherit_global_assoc() {
    let source = "\
#!/bin/bash
declare -A map
f() {
  unset map
  : \"${map[$key]:=}\"
}
f
";
    let model = model(source);

    let binding = model
        .bindings()
        .iter()
        .rev()
        .find(|binding| {
            binding.name == "map" && binding.kind == BindingKind::ParameterDefaultAssignment
        })
        .expect("expected parameter-default map binding");
    assert!(binding.attributes.contains(BindingAttributes::ARRAY));
    assert!(!binding.attributes.contains(BindingAttributes::ASSOC));
}

#[test]
fn deferred_parameter_default_after_function_unset_does_not_inherit_later_global_assoc() {
    let source = "\
#!/bin/bash
f() {
  unset map
  : \"${map[$key]:=}\"
}
declare -A map
f
";
    let model = model(source);

    let binding = model
        .bindings()
        .iter()
        .rev()
        .find(|binding| {
            binding.name == "map" && binding.kind == BindingKind::ParameterDefaultAssignment
        })
        .expect("expected parameter-default map binding");
    assert!(binding.attributes.contains(BindingAttributes::ARRAY));
    assert!(!binding.attributes.contains(BindingAttributes::ASSOC));
}

#[test]
fn deferred_parameter_default_after_global_unset_does_not_inherit_later_global_assoc() {
    let source = "\
#!/bin/bash
f() {
  : \"${map[$key]:=}\"
}
declare -A map
unset map
f
";
    let model = model(source);

    let binding = model
        .bindings()
        .iter()
        .rev()
        .find(|binding| {
            binding.name == "map" && binding.kind == BindingKind::ParameterDefaultAssignment
        })
        .expect("expected parameter-default map binding");
    assert!(binding.attributes.contains(BindingAttributes::ARRAY));
    assert!(!binding.attributes.contains(BindingAttributes::ASSOC));
}

#[test]
fn escaped_parameter_replacement_patterns_do_not_register_variable_reads() {
    let source = "\
#!/bin/bash
d=lib
origin=/tmp
echo \"${d//\\$ORIGIN/$origin}\"
";
    let model = model(source);
    let unresolved = unresolved_names(&model);

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

#[test]
fn parameter_replacement_pattern_reads_do_not_count_as_uninitialized() {
    let source = "\
#!/bin/bash
dir=all/retroarch.cfg
echo \"${dir//$configdir\\/}\"
echo \"${dir##$trim_prefix}\"
find \"$configdir\"
";
    let model = model(source);
    assert!(model.references().iter().any(|reference| {
        reference.name == "configdir"
            && reference.kind == ReferenceKind::ParameterPattern
            && reference.span.slice(source) == "$configdir"
    }));
    assert!(model.references().iter().any(|reference| {
        reference.name == "trim_prefix"
            && reference.kind == ReferenceKind::ParameterPattern
            && reference.span.slice(source) == "$trim_prefix"
    }));

    let analysis = model.analysis();
    let uninitialized = analysis.uninitialized_references();
    assert!(uninitialized.iter().any(|uninitialized| {
        let reference = model.reference(uninitialized.reference);
        reference.name == "configdir" && reference.span.slice(source) == "$configdir"
    }));
    assert!(!uninitialized.iter().any(|uninitialized| {
        let reference = model.reference(uninitialized.reference);
        reference.name == "configdir" && reference.kind == ReferenceKind::ParameterPattern
    }));
    assert!(!uninitialized.iter().any(|uninitialized| {
        let reference = model.reference(uninitialized.reference);
        reference.name == "trim_prefix" && reference.kind == ReferenceKind::ParameterPattern
    }));
}

#[test]
fn redirect_target_references_are_uninitialized_reads() {
    let source = "\
#!/bin/bash
{ echo value; } >> \"${missing_target}/out\"
echo \"${ordinary_missing}/out\"
";
    let model = model(source);
    let redirect_reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "missing_target")
        .expect("redirect target reference should be recorded");
    block_with_reference(model.analysis().cfg(), redirect_reference.id);
    let uninitialized = uninitialized_names(&model);

    assert_names_present(&["missing_target", "ordinary_missing"], &uninitialized);
}

#[test]
fn references_in_command_span_filters_to_direct_references_in_the_requested_subspan() {
    let source = "\
#!/bin/bash
foo=\"$foo\" cmd \"$foo\" \"$(printf '%s' \"$foo\")\"
";
    let output = Parser::new(source).parse().unwrap();
    let model = model(source);
    let command_id = command_id_starting_with(&model, source, "foo=\"$foo\" cmd").unwrap();
    let command_span = model.command_span(command_id);
    let Command::Simple(command) = &output.file.body[0].command else {
        panic!("expected simple command");
    };
    let assignment_span = command.assignments[0].span;
    let body_word_span = command.args[0].span;

    let assignment_refs = model
        .references_in_command_span(command_span, assignment_span)
        .map(|reference| reference.span.slice(source))
        .collect::<Vec<_>>();
    let body_refs = model
        .references_in_command_span(command_span, body_word_span)
        .map(|reference| reference.span.slice(source))
        .collect::<Vec<_>>();
    let command_refs = model
        .references_in_command_span(command_span, command_span)
        .map(|reference| reference.span.slice(source))
        .collect::<Vec<_>>();

    assert_eq!(assignment_refs, vec!["$foo"]);
    assert_eq!(body_refs, vec!["$foo"]);
    assert_eq!(command_refs, vec!["$foo", "$foo"]);
}

#[test]
fn unreachable_references_are_still_uninitialized_reads() {
    let source = "\
#!/bin/bash
load_value() {
  return 1
  printf '%s\\n' \"$after_return\"
}
load_value
";
    let model = model(source);
    let uninitialized = uninitialized_names(&model);

    assert_names_present(&["after_return"], &uninitialized);
}

#[test]
fn uninitialized_reference_certainty_lookup_matches_analysis_results() {
    let source = "\
#!/bin/bash
echo \"$definite\"
case \"$1\" in
  yes)
    possible=1
    ;;
esac
echo \"$possible\"
";
    let model = model(source);
    let analysis = model.analysis();
    let references = analysis.uninitialized_references().to_vec();

    assert!(
        !references.is_empty(),
        "expected representative uninitialized references"
    );

    for uninitialized in references {
        let reference = model.reference(uninitialized.reference);
        assert_eq!(
            analysis.uninitialized_reference_certainty_at(reference.span),
            Some(uninitialized.certainty),
            "certainty lookup should match analysis results for {}",
            reference.name
        );
    }
}

#[test]
fn zsh_option_analysis_exposes_native_defaults() {
    let source = "print $name\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let options = model
        .zsh_options_at(source.find("print").unwrap())
        .expect("expected zsh options");

    assert_eq!(options.sh_word_split, OptionValue::Off);
    assert_eq!(options.glob, OptionValue::On);
    assert_eq!(options.short_loops, OptionValue::On);
}

#[test]
fn zsh_option_analysis_tracks_setopt_updates_by_offset() {
    let source = "setopt no_glob\nprint *\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let options = model
        .zsh_options_at(source.find("print").unwrap())
        .expect("expected zsh options");

    assert_eq!(options.glob, OptionValue::Off);
}

#[test]
fn zsh_option_analysis_merges_conditionals_to_unknown_on_divergence() {
    let source = "if test \"$x\" = y; then\n  setopt no_glob\nfi\nprint *\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let options = model
        .zsh_options_at(source.find("print").unwrap())
        .expect("expected zsh options");

    assert_eq!(options.glob, OptionValue::Unknown);
}

#[test]
fn zsh_option_analysis_respects_local_options_in_functions() {
    let source = "\
fn() {
  setopt local_options no_glob
}
fn
print *
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let options = model
        .zsh_options_at(source.find("print").unwrap())
        .expect("expected zsh options");

    assert_eq!(options.glob, OptionValue::On);
}

#[test]
fn zsh_option_analysis_applies_top_level_local_options_to_function_leaks() {
    let source = "\
setopt localoptions
fn() {
  setopt no_glob
}
fn
print *
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let options = model
        .zsh_options_at(source.find("print").unwrap())
        .expect("expected zsh options");

    assert_eq!(options.glob, OptionValue::On);
}

#[test]
fn zsh_option_analysis_leaks_function_option_updates_by_default() {
    let source = "\
fn() {
  setopt sh_word_split
}
fn
print $name
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    assert!(
        model.scopes[0]
            .bindings
            .keys()
            .any(|name| name.as_str() == "fn"),
        "expected top-level function binding for `fn`"
    );
    assert!(
        model.recorded_program.function_body_scopes.len() == 1,
        "expected one recorded function body scope"
    );
    assert!(
        model
            .recorded_program
            .command_infos
            .values()
            .any(|info_id| {
                model
                    .recorded_program
                    .command_info(*info_id)
                    .static_callee
                    .as_deref()
                    == Some("fn")
            }),
        "expected a static callee for the function call"
    );
    let options = model
        .zsh_options_at(source.find("print").unwrap())
        .expect("expected zsh options");

    assert_eq!(options.sh_word_split, OptionValue::On);
}

#[test]
fn zsh_option_analysis_falls_back_to_ancestor_state_in_uncalled_function_bodies() {
    let source = "\
fn() {
  print $name
}
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let options = model
        .zsh_options_at(source.find("print").unwrap())
        .expect("expected inherited zsh options");

    assert_eq!(options.sh_word_split, OptionValue::Off);
    assert_eq!(options.glob, OptionValue::On);
}

#[test]
fn zsh_option_analysis_merges_function_snapshots_from_multiple_call_contexts() {
    let source = "\
fn() {
  print $name
}
fn
setopt sh_word_split
fn
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let options = model
        .zsh_options_at(source.find("print").unwrap())
        .expect("expected merged function zsh options");

    assert_eq!(options.sh_word_split, OptionValue::Unknown);
}

#[test]
fn zsh_option_analysis_tracks_wrapped_option_builtins() {
    let source = "\
command setopt no_glob
builtin unsetopt short_loops
print *
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let options = model
        .zsh_options_at(source.find("print").unwrap())
        .expect("expected wrapped zsh option effects");

    assert_eq!(options.glob, OptionValue::Off);
    assert_eq!(options.short_loops, OptionValue::Off);
}

#[test]
fn zsh_option_analysis_skips_assignment_prefixes_after_wrappers() {
    for source in [
        "\
command FOO=1 setopt no_glob
print *
",
        "\
noglob FOO=1 setopt no_glob
print *
",
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let options = model
            .zsh_options_at(source.find("print").unwrap())
            .expect("expected wrapped zsh option effects");

        assert_eq!(options.glob, OptionValue::Off, "{source}");
    }
}

#[test]
fn zsh_option_analysis_tracks_command_repeated_p_wrapper() {
    let source = "\
command -pp setopt no_glob
print *
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let options = model
        .zsh_options_at(source.find("print").unwrap())
        .expect("expected wrapped zsh option effects");

    assert_eq!(options.glob, OptionValue::Off);
}

#[test]
fn zsh_option_analysis_tracks_exec_bundled_option_wrappers() {
    for source in [
        "\
exec -cl setopt no_glob
print *
",
        "\
exec -lc setopt no_glob
print *
",
        "\
exec -la shuck setopt no_glob
print *
",
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let options = model
            .zsh_options_at(source.find("print").unwrap())
            .expect("expected wrapped zsh option effects");

        assert_eq!(options.glob, OptionValue::Off, "{source}");
    }
}

#[test]
fn zsh_option_analysis_ignores_external_command_wrappers() {
    for source in [
        "\
sudo setopt no_glob
print *
",
        "\
find . -exec setopt no_glob \\;
print *
",
        "\
command FOO=1 sudo setopt no_glob
print *
",
        "\
command FOO=1 find . -exec setopt no_glob \\;
print *
",
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let options = model
            .zsh_options_at(source.find("print").unwrap())
            .expect("expected wrapped zsh options");

        assert_eq!(options.glob, OptionValue::On, "{source}");
    }
}

#[test]
fn zsh_option_analysis_ignores_command_lookup_modes() {
    for source in [
        "\
command -v setopt no_glob
print *
",
        "\
command -pv setopt no_glob
print *
",
        "\
command -pV setopt no_glob
print *
",
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let options = model
            .zsh_options_at(source.find("print").unwrap())
            .expect("expected wrapped zsh options");

        assert_eq!(options.glob, OptionValue::On, "{source}");
    }
}

#[test]
fn zsh_option_analysis_ignores_unsupported_precommand_options() {
    for source in [
        "\
command -x setopt no_glob
print *
",
        "\
builtin -x setopt no_glob
print *
",
        "\
exec -x setopt no_glob
print *
",
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let options = model
            .zsh_options_at(source.find("print").unwrap())
            .expect("expected wrapped zsh options");

        assert_eq!(options.glob, OptionValue::On, "{source}");
    }
}

#[test]
fn zsh_option_analysis_tracks_ksh_arrays_updates_by_offset() {
    for (source, expected) in [
        ("setopt ksh_arrays\nprint $name\n", OptionValue::On),
        ("setopt ksharrays\nprint $name\n", OptionValue::On),
        ("unsetopt no_ksh_arrays\nprint $name\n", OptionValue::On),
        ("unsetopt no-ksh-arrays\nprint $name\n", OptionValue::On),
        ("setopt -- ksh_arrays\nprint $name\n", OptionValue::On),
        ("unsetopt -- no_ksh_arrays\nprint $name\n", OptionValue::On),
        ("emulate ksh\nprint $name\n", OptionValue::On),
        ("emulate -R ksh\nprint $name\n", OptionValue::On),
        ("unsetopt ksh_arrays\nprint $name\n", OptionValue::Off),
        ("unsetopt ksharrays\nprint $name\n", OptionValue::Off),
        ("setopt no_ksh_arrays\nprint $name\n", OptionValue::Off),
        ("setopt no-ksh-arrays\nprint $name\n", OptionValue::Off),
        ("emulate zsh\nprint $name\n", OptionValue::Off),
        ("emulate sh\nprint $name\n", OptionValue::Off),
        ("emulate csh\nprint $name\n", OptionValue::Off),
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let options = model
            .zsh_options_at(source.find("print").unwrap())
            .expect("expected zsh options");

        assert_eq!(options.ksh_arrays, expected, "{source}");
    }
}

#[test]
fn zsh_option_analysis_treats_dynamic_ksh_arrays_updates_as_unknown() {
    for source in [
        "opt=ksh_arrays\nsetopt \"$opt\"\nprint $name\n",
        "opt=ksh_arrays\nset -o \"$opt\"\nprint $name\n",
        "opt=ksh_arrays\nemulate -o \"$opt\" zsh\nprint $name\n",
        "opt=no_ksh_arrays\nunsetopt \"$opt\"\nprint $name\n",
        "opt=no_ksh_arrays\nset +o \"$opt\"\nprint $name\n",
        "setopt -m 'ksh*'\nprint $name\n",
        "unsetopt -m 'no_ksh*'\nprint $name\n",
        "mode=ksh\nemulate \"$mode\"\nprint $name\n",
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let options = model
            .zsh_options_at(source.find("print").unwrap())
            .expect("expected zsh options");

        assert_eq!(options.ksh_arrays, OptionValue::Unknown, "{source}");
    }
}

fn array_reference_policy_at(model: &SemanticModel, offset: usize) -> ArrayReferencePolicy {
    model.shell_behavior_at(offset).array_reference_policy()
}

#[test]
fn semantic_behavior_tracks_subscript_indexing_options() {
    let bash_source = "print ${arr[0]}\n";
    let bash_model = model_with_profile(bash_source, ShellProfile::native(ShellDialect::Bash));
    let bash_offset = bash_source.find("print").unwrap();
    assert_eq!(
        bash_model
            .shell_behavior_at(bash_offset)
            .subscript_indexing(),
        SubscriptIndexBehavior::ZeroBased,
    );

    for (source, expected) in [
        ("print ${arr[0]}\n", SubscriptIndexBehavior::OneBased),
        (
            "setopt ksh_arrays\nprint ${arr[1]}\n",
            SubscriptIndexBehavior::ZeroBased,
        ),
        (
            "setopt ksh_arrays ksh_zero_subscript\nprint ${arr[0]}\n",
            SubscriptIndexBehavior::ZeroBased,
        ),
        (
            "setopt ksh_arrays\nif cond; then setopt ksh_zero_subscript; fi\nprint ${arr[1]}\n",
            SubscriptIndexBehavior::ZeroBased,
        ),
        (
            "setopt ksh_arrays\nunsetopt ksh_arrays\nprint ${arr[1]}\n",
            SubscriptIndexBehavior::OneBased,
        ),
        (
            "setopt ksh_zero_subscript\nunsetopt ksh_zero_subscript\nprint ${arr[0]}\n",
            SubscriptIndexBehavior::OneBased,
        ),
        (
            "setopt ksh_zero_subscript\nprint ${arr[0]}\n",
            SubscriptIndexBehavior::OneBasedWithZeroAlias,
        ),
        (
            "opt=ksh_zero_subscript\nsetopt \"$opt\"\nprint ${arr[0]}\n",
            SubscriptIndexBehavior::Ambiguous,
        ),
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let offset = source.find("print").unwrap();

        assert_eq!(
            model.shell_behavior_at(offset).subscript_indexing(),
            expected,
            "{source}"
        );
    }
}

#[test]
fn semantic_behavior_tracks_arithmetic_literal_options() {
    let bash_source = "print $(( 010 + 0x10 ))\n";
    let bash_model = model_with_profile(bash_source, ShellProfile::native(ShellDialect::Bash));
    let bash_offset = bash_source.find("print").unwrap();
    assert_eq!(
        bash_model
            .shell_behavior_at(bash_offset)
            .arithmetic_literals(),
        ArithmeticLiteralBehavior::CStyleAndLeadingZeroOctal,
    );

    for (source, expected) in [
        (
            "print $(( 010 + 0x10 ))\n",
            ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
        ),
        (
            "setopt c_bases\nprint $(( 010 + 0x10 ))\n",
            ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
        ),
        (
            "setopt octal_zeroes\nprint $(( 010 + 0x10 ))\n",
            ArithmeticLiteralBehavior::LeadingZeroOctal,
        ),
        (
            "setopt c_bases octal_zeroes\nprint $(( 010 + 0x10 ))\n",
            ArithmeticLiteralBehavior::LeadingZeroOctal,
        ),
        (
            "opt=octal_zeroes\nsetopt \"$opt\"\nprint $(( 010 + 0x10 ))\n",
            ArithmeticLiteralBehavior::Ambiguous,
        ),
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let offset = source.find("print").unwrap();

        assert_eq!(
            model.shell_behavior_at(offset).arithmetic_literals(),
            expected,
            "{source}"
        );
    }
}

#[test]
fn semantic_behavior_detects_real_ksh_array_enables() {
    for source in [
        "setopt ksh_arrays\nprint $name\n",
        "setopt ksharrays\nprint $name\n",
        "unsetopt no_ksh_arrays\nprint $name\n",
        "unsetopt no-ksh-arrays\nprint $name\n",
        "setopt -- ksh_arrays\nprint $name\n",
        "unsetopt -- no_ksh_arrays\nprint $name\n",
        "emulate ksh\nprint $name\n",
        "emulate -L ksh\nprint $name\n",
        "emulate -R ksh\nprint $name\n",
        "opt=ksh_arrays\nsetopt \"$opt\"\nprint $name\n",
        "opt=ksh_arrays\nset -o \"$opt\"\nprint $name\n",
        "opt=no_ksh_arrays\nunsetopt \"$opt\"\nprint $name\n",
        "opt=no_ksh_arrays\nset +o \"$opt\"\nprint $name\n",
        "setopt -m 'ksh*'\nprint $name\n",
        "unsetopt -m 'no_ksh*'\nprint $name\n",
        "mode=ksh\nemulate \"$mode\"\nprint $name\n",
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let offset = source.find("print").unwrap();
        assert_ne!(
            array_reference_policy_at(&model, offset),
            ArrayReferencePolicy::NativeZshScalar,
            "{source}"
        );
    }
}

#[test]
fn semantic_behavior_ignores_non_effect_text_for_ksh_arrays() {
    for source in [
        "# emulate ksh in comments should not count\nprint $name\n",
        "msg='setopt ksh_arrays'\nprint $name\n",
        "setopt no_ksh_arrays\nprint $name\n",
        "emulate sh\nprint $name\n",
        "emulate zsh\nprint $name\n",
        "unsetopt ksh_arrays\nprint $name\n",
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let offset = source.find("print").unwrap();
        assert_eq!(
            array_reference_policy_at(&model, offset),
            ArrayReferencePolicy::NativeZshScalar,
            "{source}"
        );
    }
}

#[test]
fn semantic_shell_behavior_uses_selector_policy_for_non_zsh_profiles() {
    let source = "setopt ksh_arrays\nprint $name\n";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Bash));
    let offset = source.find("print").unwrap();

    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::RequiresExplicitSelector,
        "{source}"
    );
}

#[test]
fn reference_array_use_kind_tracks_zsh_runtime_policy_states() {
    for (source, expected) in [
        (
            "\
#!/bin/zsh
arr=(one two)
print $arr
",
            Some(ArrayReferencePolicy::NativeZshScalar),
        ),
        (
            "\
#!/bin/zsh
setopt ksh_arrays
arr=(one two)
print $arr
",
            Some(ArrayReferencePolicy::RequiresExplicitSelector),
        ),
        (
            "\
#!/bin/zsh
opt=ksh_arrays
setopt \"$opt\"
arr=(one two)
print $arr
",
            Some(ArrayReferencePolicy::Ambiguous),
        ),
    ] {
        let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
        let reference = model
            .references()
            .iter()
            .find(|reference| reference.name == "arr")
            .expect("expected arr reference");

        assert_eq!(
            model.reference_array_use_kind(reference.id),
            expected,
            "{source}"
        );
    }
}

#[test]
fn reference_array_use_kind_preserves_inherited_array_shape() {
    let source = "\
#!/bin/bash
declare -a items
items=scalar
printf '%s\\n' \"$items\"
";
    let model = model(source);
    let reference = model
        .references()
        .iter()
        .find(|reference| reference.name == "items")
        .expect("expected items reference");

    assert_eq!(
        model.reference_array_use_kind(reference.id),
        Some(ArrayReferencePolicy::RequiresExplicitSelector)
    );
}

#[test]
fn reference_array_use_kind_suppresses_after_zsh_scalar_local_barrier() {
    let source = "\
#!/bin/zsh
items=(one two)
fn() {
  local items=value
  print $items
}
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let reference = model
        .references()
        .iter()
        .rfind(|reference| reference.name == "items")
        .expect("expected items reference");

    assert_eq!(model.reference_array_use_kind(reference.id), None);
}

#[test]
fn reference_array_use_kind_reports_bash_runtime_arrays_without_bash_profile() {
    let source = "\
#!/bin/sh
MAPFILE=scalar
printf '%s\\n' \"$MAPFILE\" \"$BASH_SOURCE\"
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Posix));
    let mapfile = model
        .references()
        .iter()
        .find(|reference| reference.name == "MAPFILE")
        .expect("expected MAPFILE reference");
    let bash_source = model
        .references()
        .iter()
        .find(|reference| reference.name == "BASH_SOURCE")
        .expect("expected BASH_SOURCE reference");

    assert_eq!(
        model.reference_array_use_kind(mapfile.id),
        Some(ArrayReferencePolicy::RequiresExplicitSelector)
    );
    assert_eq!(
        model.reference_array_use_kind(bash_source.id),
        Some(ArrayReferencePolicy::RequiresExplicitSelector)
    );
}

#[test]
fn semantic_runtime_ksh_arrays_state_requires_guaranteed_function_local_disable() {
    let source = "\
fn() {
  if [[ -n $flag ]]; then
    emulate -LR zsh
  fi
  print $name
}
dispatcher=fn
setopt ksh_arrays
$dispatcher
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let offset = source.find("print $name").unwrap();

    assert!(
        model
            .zsh_options_at(offset)
            .is_some_and(|options| options.ksh_arrays == OptionValue::Unknown),
        "{source}"
    );
    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::Ambiguous,
        "{source}"
    );
}

#[test]
fn semantic_runtime_ksh_arrays_state_keeps_unconditional_function_local_disable() {
    let source = "\
fn() {
  emulate -LR zsh
  print $name
}
dispatcher=fn
setopt ksh_arrays
$dispatcher
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let offset = source.find("print $name").unwrap();

    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::NativeZshScalar,
        "{source}"
    );
}

#[test]
fn semantic_runtime_ksh_arrays_state_tracks_top_level_indirect_calls() {
    let source = "\
fn() {
  emulate ksh
}
dispatcher=fn
$dispatcher
print $name
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let offset = source.find("print $name").unwrap();

    assert!(
        model
            .zsh_options_at(offset)
            .is_some_and(|options| options.ksh_arrays == OptionValue::On),
        "{source}"
    );
    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::RequiresExplicitSelector,
        "{source}"
    );
}

#[test]
fn semantic_runtime_ksh_arrays_state_treats_unresolved_dispatch_as_ambiguous() {
    let source = "\
enable_ksh() {
  emulate ksh
}
$dispatcher
print $name
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let offset = source.find("print $name").unwrap();

    assert!(
        model
            .zsh_options_at(offset)
            .is_some_and(|options| options.ksh_arrays == OptionValue::Unknown),
        "{source}"
    );
    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::Ambiguous,
        "{source}"
    );
}

#[test]
fn semantic_runtime_ksh_arrays_state_recovers_after_top_level_explicit_disable() {
    let source = "\
enable_ksh() {
  emulate ksh
}
dispatcher=enable_ksh
$dispatcher
unsetopt ksh_arrays
print $name
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let offset = source.find("print $name").unwrap();

    assert!(
        model
            .zsh_options_at(offset)
            .is_some_and(|options| options.ksh_arrays == OptionValue::Off),
        "{source}"
    );
    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::NativeZshScalar,
        "{source}"
    );
}

#[test]
fn semantic_runtime_ksh_arrays_state_tracks_wrapped_top_level_indirect_calls() {
    let source = "\
enable_ksh() {
  emulate ksh
}
dispatcher=enable_ksh
noglob $dispatcher
print $name
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let offset = source.find("print $name").unwrap();

    assert!(
        model
            .zsh_options_at(offset)
            .is_some_and(|options| options.ksh_arrays == OptionValue::On),
        "{source}"
    );
    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::RequiresExplicitSelector,
        "{source}"
    );
}

#[test]
fn semantic_runtime_ksh_arrays_state_ignores_command_wrapped_dynamic_calls() {
    let source = "\
enable_ksh() {
  emulate ksh
}
dispatcher=enable_ksh
command $dispatcher
print $name
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let offset = source.find("print $name").unwrap();

    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::NativeZshScalar,
        "{source}"
    );
}

#[test]
fn semantic_runtime_ksh_arrays_state_prefers_latest_visible_dynamic_target() {
    let source = "\
fn() {
  emulate ksh
}
fn() {
  unsetopt ksh_arrays
}
dispatcher=fn
setopt ksh_arrays
$dispatcher
print $name
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let offset = source.find("print $name").unwrap();

    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::NativeZshScalar,
        "{source}"
    );
}

#[test]
fn semantic_runtime_ksh_arrays_state_treats_unknown_dispatcher_binding_as_ambiguous() {
    let source = "\
enable_ksh() {
  emulate ksh
}
run_dispatcher() {
  unsetopt ksh_arrays
  dispatcher=$1
  $dispatcher
  print $name
}
run_dispatcher enable_ksh
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let offset = source.find("print $name").unwrap();

    assert!(
        model
            .zsh_options_at(offset)
            .is_some_and(|options| options.ksh_arrays == OptionValue::Off),
        "{source}"
    );
    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::Ambiguous,
        "{source}"
    );
}

#[test]
fn semantic_runtime_zsh_option_analysis_reuses_dynamic_function_summaries() {
    let mut source = String::from("#!/bin/zsh\n");
    for index in 0..12 {
        source.push_str(&format!(
            "\
f{index}() {{
  $dispatcher
}}
"
        ));
    }
    source.push_str(
        "\
enable_ksh() {
  emulate ksh
}
run_dispatcher() {
  $dispatcher
  print $name
}
run_dispatcher
",
    );

    let model = model_with_profile(&source, ShellProfile::native(ShellDialect::Zsh));
    let offset = source.find("print $name").unwrap();

    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::Ambiguous
    );
}

#[test]
fn semantic_runtime_zsh_option_analysis_records_cached_root_function_snapshots() {
    let source = "\
setopt ksh_arrays
unsetopt ksh_arrays
callee() {
  print $name
}
caller() {
  callee
}
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let caller_call_offset = source.rfind("callee").unwrap();
    let callee_print_offset = source.find("print $name").unwrap();

    assert_eq!(
        array_reference_policy_at(&model, caller_call_offset),
        ArrayReferencePolicy::Ambiguous
    );
    assert!(
        model
            .shell_behavior_at(callee_print_offset)
            .zsh_options()
            .is_some_and(|options| options.ksh_arrays == OptionValue::Unknown),
        "{source}"
    );
    assert_eq!(
        array_reference_policy_at(&model, callee_print_offset),
        ArrayReferencePolicy::Ambiguous
    );
}

#[test]
fn semantic_runtime_zsh_option_analysis_keys_summaries_by_recursion_context() {
    let source = "\
setopt ksh_arrays
unsetopt ksh_arrays
f() {
  g
}
g() {
  h
}
h() {
  g
  print $name
  unsetopt ksh_arrays
}
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let f_call_offset = source.find("  g\n").unwrap() + 2;
    let h_print_offset = source.find("print $name").unwrap();

    assert_eq!(
        array_reference_policy_at(&model, f_call_offset),
        ArrayReferencePolicy::Ambiguous
    );
    assert!(
        model
            .shell_behavior_at(h_print_offset)
            .zsh_options()
            .is_some_and(|options| options.ksh_arrays == OptionValue::Unknown),
        "{source}"
    );
    assert_eq!(
        array_reference_policy_at(&model, h_print_offset),
        ArrayReferencePolicy::Ambiguous
    );
}

#[test]
fn semantic_runtime_zsh_option_summary_cache_keeps_recursive_context_local() {
    let source = "\
unsetopt ksh_arrays
f() {
  g
  setopt ksh_arrays
}
g() {
  f
}
f
unsetopt ksh_arrays
g
print $name
";
    let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
    let offset = source.find("print $name").unwrap();

    assert!(
        model
            .shell_behavior_at(offset)
            .zsh_options()
            .is_some_and(|options| options.ksh_arrays == OptionValue::On),
        "{source}"
    );
    assert_eq!(
        array_reference_policy_at(&model, offset),
        ArrayReferencePolicy::RequiresExplicitSelector
    );
}
