#![warn(missing_docs)]
#![cfg_attr(not(test), warn(clippy::unwrap_used))]

//! Semantic analysis for shell scripts parsed by Shuck.
//!
//! The semantic model tracks scopes, bindings, references, control flow, and selected dataflow
//! facts so higher-level crates can reason about shell behavior without re-traversing the AST.
mod analysis;
mod array_use;
mod binding;
mod builder;
mod call_facts;
mod call_graph;
mod cfg;
mod command_topology;
mod contract;
mod dataflow;
mod declaration;
mod dense_bit_set;
mod editor;
mod function_call_reachability;
mod function_resolution;
mod glob;
mod nonpersistent;
mod reachability;
mod reference;
mod runtime;
mod scope;
mod source_closure;
mod source_ref;
mod source_resolve;
mod uninitialized;
mod unused;
mod value_flow;
mod zsh_options;
mod zsh_plugin_framework;

/// Binding types and provenance metadata discovered during semantic analysis.
pub use binding::{
    AssignmentValueOrigin, Binding, BindingAttributes, BindingId, BindingKind, BindingOrigin,
    BuiltinBindingTargetKind, LoopValueOrigin,
};
/// Call-graph structures derived from the analyzed script.
/// Workspace call-graph index types for cross-file call hierarchy.
pub use call_facts::{
    CallFactDefinition, CallFactSite, CallFactSourceEdge, CallFunctionId, CallNodeKind,
    CrossFileCall, ExactFunctionReference, ExactFunctionRename, ExactFunctionRenameError,
    FileCallFacts, VisibleSourcedFunction, WorkspaceCallIndex,
};
pub use call_graph::{
    CallGraph, CallSite, OverwrittenFunction, UnreachedFunction, UnreachedFunctionReason,
};
/// Control-flow graph types and flow-context annotations.
pub use cfg::{
    BasicBlock, BlockId, BuiltinCommandKind, CommandConditionRole, CommandId, CommandKind,
    CompoundCommandKind, ControlFlowGraph, EdgeKind, FlowContext, StatementSequenceCommand,
    UnreachableCauseKind,
};
/// Command topology and flattened command-shape query types.
pub use command_topology::{
    SemanticCommandContext, SemanticListCommand, SemanticListOperator, SemanticListOperatorKind,
    SemanticListSegment, SemanticPipelineCommand, SemanticPipelineOperator,
    SemanticPipelineOperatorKind, SemanticPipelineSegment,
};
/// Contract and build-option types used when constructing semantic models.
pub use contract::{
    ContractCertainty, FileContract, FileEntryBindingInitialization, FileEntryContractCollector,
    FileEntryContractCollectorFactory, FunctionContract, ProvidedBinding, ProvidedBindingKind,
    SemanticBuildOptions,
};
/// Dataflow results surfaced by the semantic analysis layer.
pub use dataflow::{
    DeadCode, ReachingDefinitions, UninitializedCertainty, UninitializedReference,
    UnusedAssignment, UnusedReason,
};
/// Declaration records discovered while building the semantic model.
pub use declaration::{Declaration, DeclarationBuiltin, DeclarationOperand};
/// Editor-facing semantic query types.
pub use editor::{
    EditorCallHierarchyItem, EditorCallHierarchyTarget, EditorCompletion, EditorCompletionContext,
    EditorCompletionKind, EditorCompletionOptions, EditorCompletions, EditorDocumentSymbol,
    EditorFunctionCallTarget, EditorHover, EditorIncomingCall, EditorOccurrence,
    EditorOccurrenceKind, EditorOutgoingCall, EditorQuery, EditorRuntimeNameTarget, EditorSymbol,
    EditorSymbolKind, EditorSymbolTarget, RenameSet, RenameUnavailable,
};
/// Direct function-call reachability query types.
pub use function_call_reachability::{
    DirectFunctionCallReachability, DirectFunctionCallWindow, FunctionCallCandidate,
    FunctionCallPersistence,
};
/// Option-sensitive globbing and expansion behavior queries.
pub use glob::{
    BraceCharacterClassBehavior, FieldSplittingBehavior, FileExpansionOrderBehavior,
    GlobDotBehavior, GlobFailureBehavior, GlobPatternBehavior, PathnameExpansionBehavior,
    PatternOperatorBehavior,
};
/// Nonpersistent assignment effects, such as assignments made in subshells and read later outside.
pub use nonpersistent::{
    NonpersistentAssignmentAnalysis, NonpersistentAssignmentAnalysisContext,
    NonpersistentAssignmentAnalysisOptions, NonpersistentAssignmentCommandContext,
    NonpersistentAssignmentEffect, NonpersistentAssignmentExtraRead, NonpersistentLaterUseKind,
};
/// Reference types and identifiers tracked by the semantic model.
pub use reference::{Reference, ReferenceId, ReferenceKind};
/// Scope types and identifiers tracked by the semantic model.
pub use scope::{FunctionScopeKind, Scope, ScopeId, ScopeKind};
/// Shell parser option types reused by the semantic analysis layer.
pub use shuck_parser::{OptionValue, ShellDialect, ShellProfile, ZshEmulationMode, ZshOptionState};
/// Zsh plugin framework layout helpers.
pub use source_closure::{layout_for_plugin_framework, zsh_plugin_frameworks};
/// Source-reference records and resolution state.
pub use source_ref::{
    SourceDirectiveInfo, SourceDirectiveOrigin, SourceRef, SourceRefDiagnosticClass, SourceRefKind,
    SourceRefResolution,
};
/// Shared on-disk resolution for source references and hints.
pub use source_resolve::{
    resolve_candidate_against_roots, resolve_candidate_targets, resolve_source_ref_targets,
    source_ref_candidate_paths,
};
/// Value-flow query object built over semantic bindings, call sites, CFG, and dataflow.
pub use value_flow::SemanticValueFlow;
/// Zsh plugin framework traits and name aliases.
pub use zsh_plugin_framework::{
    ZshPluginFramework, resolve_zsh_plugin_entrypoint, resolve_zsh_plugin_source_paths,
    zsh_plugin_framework_from_name, zsh_plugin_root_keys,
};

/// How an unindexed array reference behaves at a source offset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArrayReferencePolicy {
    /// The shell requires an explicit element or selector for array references.
    RequiresExplicitSelector,
    /// Native zsh treats an unindexed array reference as a scalar first-element read.
    NativeZshScalar,
    /// Runtime option state may select either policy.
    Ambiguous,
}

/// How indexed array subscripts are interpreted at a source offset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubscriptIndexBehavior {
    /// Subscript `1` names the first array element.
    OneBased,
    /// Subscript `0` names the first array element.
    ZeroBased,
    /// Subscript `1` names the first element, but `0` is accepted as an alias.
    OneBasedWithZeroAlias,
    /// Runtime option state may select more than one indexing policy.
    Ambiguous,
}

/// How arithmetic number literals are interpreted at a source offset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArithmeticLiteralBehavior {
    /// Decimal literals are used unless an explicit shell arithmetic base is present.
    DecimalUnlessExplicitBase,
    /// Leading zeroes denote octal literals.
    LeadingZeroOctal,
    /// Both C-style base prefixes and leading-zero octal literals are active.
    CStyleAndLeadingZeroOctal,
    /// Runtime option state may select more than one literal policy.
    Ambiguous,
}

/// Option-sensitive shell behavior visible at a source offset.
#[derive(Debug, Clone)]
pub struct ShellBehaviorAt<'model> {
    shell: ShellDialect,
    zsh_options: Option<&'model ZshOptionState>,
    runtime_options: Option<ZshOptionState>,
    zsh_sh_emulation: Option<bool>,
}

impl ShellBehaviorAt<'_> {
    fn effective_zsh_options(&self) -> Option<&ZshOptionState> {
        self.runtime_options.as_ref().or(self.zsh_options)
    }

    /// Returns the effective zsh option state when this behavior is for zsh.
    pub fn zsh_options(&self) -> Option<&ZshOptionState> {
        self.effective_zsh_options()
    }

    /// Returns the shell dialect this behavior was computed for.
    pub fn shell_dialect(&self) -> ShellDialect {
        self.shell
    }

    /// Returns whether this offset is in zsh's `emulate sh`-style compatibility behavior.
    pub fn zsh_sh_emulation(&self) -> bool {
        self.shell == ShellDialect::Zsh && self.zsh_sh_emulation.unwrap_or(false)
    }

    /// Creates behavior for a shell dialect without source-local option state.
    pub fn for_dialect(shell: ShellDialect) -> Self {
        Self {
            shell,
            zsh_options: None,
            runtime_options: None,
            zsh_sh_emulation: None,
        }
    }

    /// Returns this behavior with a caller-provided zsh option overlay applied.
    ///
    /// Non-zsh behavior is returned unchanged. For zsh, the overlay starts with the
    /// effective source-local option state, or the native zsh defaults when no
    /// source-local state is available.
    pub fn with_zsh_option_overlay(mut self, overlay: impl FnOnce(&mut ZshOptionState)) -> Self {
        if self.shell == ShellDialect::Zsh {
            let mut options = self
                .effective_zsh_options()
                .cloned()
                .unwrap_or_else(ZshOptionState::zsh_default);
            overlay(&mut options);
            self.runtime_options = Some(options);
        }
        self
    }

    /// Returns the array-reference policy implied by the shell and runtime option state.
    pub fn array_reference_policy(&self) -> ArrayReferencePolicy {
        if self.shell != ShellDialect::Zsh {
            return ArrayReferencePolicy::RequiresExplicitSelector;
        }

        match self
            .effective_zsh_options()
            .map(|options| options.ksh_arrays)
        {
            Some(OptionValue::Off) => ArrayReferencePolicy::NativeZshScalar,
            Some(OptionValue::Unknown) => ArrayReferencePolicy::Ambiguous,
            Some(OptionValue::On) | None => ArrayReferencePolicy::RequiresExplicitSelector,
        }
    }

    /// Returns how array subscript indexes are interpreted.
    pub fn subscript_indexing(&self) -> SubscriptIndexBehavior {
        if self.shell != ShellDialect::Zsh {
            return SubscriptIndexBehavior::ZeroBased;
        }

        let Some(options) = self.effective_zsh_options() else {
            return SubscriptIndexBehavior::OneBased;
        };

        match (options.ksh_arrays, options.ksh_zero_subscript) {
            (OptionValue::On, _) => SubscriptIndexBehavior::ZeroBased,
            (OptionValue::Unknown, _) | (OptionValue::Off, OptionValue::Unknown) => {
                SubscriptIndexBehavior::Ambiguous
            }
            (OptionValue::Off, OptionValue::On) => SubscriptIndexBehavior::OneBasedWithZeroAlias,
            (OptionValue::Off, OptionValue::Off) => SubscriptIndexBehavior::OneBased,
        }
    }

    /// Returns how arithmetic numeric literals are interpreted.
    pub fn arithmetic_literals(&self) -> ArithmeticLiteralBehavior {
        if self.shell != ShellDialect::Zsh {
            return ArithmeticLiteralBehavior::CStyleAndLeadingZeroOctal;
        }

        let Some(options) = self.effective_zsh_options() else {
            return ArithmeticLiteralBehavior::DecimalUnlessExplicitBase;
        };

        match options.octal_zeroes {
            OptionValue::Off => ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
            OptionValue::On => ArithmeticLiteralBehavior::LeadingZeroOctal,
            OptionValue::Unknown => ArithmeticLiteralBehavior::Ambiguous,
        }
    }
}

/// A function scope reached through a top-level `case "$1"` style CLI dispatcher.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CaseCliDispatch {
    function_scope: ScopeId,
    dispatcher_span: Span,
}

impl CaseCliDispatch {
    fn new(function_scope: ScopeId, dispatcher_span: Span) -> Self {
        Self {
            function_scope,
            dispatcher_span,
        }
    }

    /// The function body scope selected by the dispatcher.
    pub fn function_scope(self) -> ScopeId {
        self.function_scope
    }

    /// The span of the dynamic positional command used for dispatch.
    pub fn dispatcher_span(self) -> Span {
        self.dispatcher_span
    }
}

use rustc_hash::{FxHashMap, FxHashSet};
use shuck_ast::{Command, ConditionalExpr, File, Name, Span, Stmt, TextRange};
use shuck_indexer::Indexer;
use smallvec::{Array, SmallVec};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

use crate::builder::SemanticModelBuilder;
use crate::call_graph::build_call_graph;
use crate::cfg::RecordedProgram;
use crate::command_topology::CommandTopology;
#[cfg(test)]
use crate::dataflow::DataflowResult;
use crate::dataflow::{DataflowContext, ExactVariableDataflow};
use crate::function_resolution::FunctionBindingLookup;
use crate::runtime::RuntimePrelude;
use crate::scope::{ancestor_scopes, enclosing_function_scope};
use crate::zsh_options::ZshOptionAnalysis;

const MAX_FUNCTIONS_FOR_TERMINATION_REACHABILITY: usize = 200;
const MAX_TERMINATION_REACHABILITY_WORK: usize = 20_000;

struct AssocCallerSeenNames {
    inline: SmallVec<[Name; 8]>,
    hashed: Option<FxHashSet<Name>>,
}

impl AssocCallerSeenNames {
    const HASH_THRESHOLD: usize = 32;

    fn new() -> Self {
        Self {
            inline: SmallVec::new(),
            hashed: None,
        }
    }

    fn insert(&mut self, name: &Name) -> bool {
        if let Some(hashed) = &mut self.hashed {
            return hashed.insert(name.clone());
        }

        if self.inline.iter().any(|seen_name| seen_name == name) {
            return false;
        }

        if self.inline.len() < Self::HASH_THRESHOLD {
            self.inline.push(name.clone());
            return true;
        }

        let mut hashed =
            FxHashSet::with_capacity_and_hasher(Self::HASH_THRESHOLD * 2, Default::default());
        hashed.extend(self.inline.drain(..));
        let inserted = hashed.insert(name.clone());
        self.hashed = Some(hashed);
        inserted
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct SpanKey {
    start: usize,
    end: usize,
}

impl SpanKey {
    pub(crate) fn new(span: Span) -> Self {
        Self {
            start: span.start.offset(),
            end: span.end.offset(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SourceDirectiveOverride {
    pub(crate) kind: SourceRefKind,
    /// Origin and lint policy of the directive.
    pub(crate) directive: SourceDirectiveInfo,
    /// Byte range of the value after `source=` in the directive comment.
    pub(crate) path_range: TextRange,
    pub(crate) own_line: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum IndirectTargetHint {
    Exact {
        name: Name,
        array_like: bool,
    },
    Pattern {
        prefix: compact_str::CompactString,
        suffix: compact_str::CompactString,
        array_like: bool,
    },
}

/// Synthetic read introduced by semantic modeling for later analysis passes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyntheticRead {
    pub(crate) scope: ScopeId,
    pub(crate) span: Span,
    pub(crate) name: Name,
}

/// Summary of positional-parameter reads reachable from a function scope.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FunctionPositionalReferenceSummary {
    required_arg_count: usize,
    uses_unprotected_positional_parameters: bool,
}

impl FunctionPositionalReferenceSummary {
    /// Returns the highest positional argument index that is definitely required.
    pub fn required_arg_count(self) -> usize {
        self.required_arg_count
    }

    /// Returns whether the function reads positional parameters without a guarding default.
    pub fn uses_unprotected_positional_parameters(self) -> bool {
        self.uses_unprotected_positional_parameters
    }

    fn record_required_arg_count(&mut self, index: usize) {
        self.required_arg_count = self.required_arg_count.max(index);
        self.uses_unprotected_positional_parameters = true;
    }

    fn record_use(&mut self) {
        self.uses_unprotected_positional_parameters = true;
    }
}

/// Behavior flags for unused-assignment analysis.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct UnusedAssignmentAnalysisOptions {
    /// Whether a resolved scalar indirect-expansion target like `${!name}` counts as a use
    /// of the target. ShellCheck-compatible analysis leaves this disabled. Array-like
    /// targets such as `name=arr[@]; ${!name}` stay live in both modes.
    pub treat_indirect_expansion_targets_as_used: bool,
    /// Whether assignments in statically unreachable blocks should still be eligible
    /// for unused-assignment reporting.
    pub report_unreachable_assignments: bool,
}

/// Behavior flags for unreached-function analysis.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct UnreachedFunctionAnalysisOptions {
    /// Whether nested function definitions should be reported when their enclosing function scope
    /// is not reached by a direct call chain.
    pub report_unreached_nested_definitions: bool,
}

impl SyntheticRead {
    /// Returns the scope where the synthetic read should be considered visible.
    pub fn scope(&self) -> ScopeId {
        self.scope
    }

    /// Returns the span that higher layers should attribute to the synthetic read.
    pub fn span(&self) -> Span {
        self.span
    }

    /// Returns the runtime name read by this synthetic entry.
    pub fn name(&self) -> &Name {
        &self.name
    }
}

#[doc(hidden)]
pub trait TraversalObserver<'a> {
    fn enter_command(&mut self, _command: &Command, _scope: ScopeId, _flow: FlowContext) {}

    fn exit_command(&mut self, _command: &Command, _scope: ScopeId) {}

    fn conditional_expression(
        &mut self,
        _command_span: Span,
        _expression: &'a ConditionalExpr,
        _parent_in_same_logical_group: bool,
    ) {
    }

    fn recorded_command(
        &mut self,
        _id: CommandId,
        _stmt: &'a Stmt,
        _scope: ScopeId,
        _flow: FlowContext,
    ) {
    }

    fn recorded_statement_sequence_command(
        &mut self,
        _body_span: Span,
        _stmt_span: Span,
        _id: CommandId,
    ) {
    }

    fn record_binding(&mut self, _binding: &Binding) {}

    fn record_reference(&mut self, _reference: &Reference, _resolved: Option<&Binding>) {}
}

#[doc(hidden)]
pub struct NoopTraversalObserver;

impl<'a> TraversalObserver<'a> for NoopTraversalObserver {}

#[doc(hidden)]
pub trait SourcePathResolver {
    fn resolve_candidate_paths(&self, source_path: &Path, candidate: &str) -> Vec<PathBuf>;
}

impl<F> SourcePathResolver for F
where
    F: Fn(&Path, &str) -> Vec<PathBuf> + Send + Sync,
{
    fn resolve_candidate_paths(&self, source_path: &Path, candidate: &str) -> Vec<PathBuf> {
        self(source_path, candidate)
    }
}

/// Known plugin frameworks that can resolve logical plugin names into entrypoint files.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PluginFramework {
    /// oh-my-zsh style plugin and theme layouts.
    OhMyZsh,
    /// Prezto module layouts.
    Prezto,
    /// zdot module layouts.
    Zdot,
    /// Zinit/Zi plugin manager layouts.
    Zinit,
    /// An explicit filesystem path rather than a logical framework/plugin pair.
    ExplicitFilesystem,
    /// A custom framework name reserved for future extension points.
    Other(String),
}

/// The kind of plugin request being resolved.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PluginRequestKind {
    /// A logical framework plugin such as `git`.
    Plugin,
    /// A logical framework theme such as `agnoster`.
    Theme,
    /// An explicit raw entrypoint path.
    Entrypoint,
}

/// A single plugin load request extracted from source or configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginRequest {
    /// Framework used to resolve this request.
    pub framework: PluginFramework,
    /// Request category.
    pub kind: PluginRequestKind,
    /// Plugin name, theme name, or raw entrypoint path depending on `kind`.
    pub name: String,
    /// Source span used to anchor imported bindings and reads.
    pub span: Span,
    /// Whether the request came from explicit user configuration.
    pub explicit: bool,
    /// Concrete framework root inferred from the source file when one is available.
    pub root_hint: Option<PathBuf>,
}

/// Resolved plugin entrypoints and optional future plugin contracts.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginResolution {
    /// Plugin entrypoint files to summarize and import.
    pub entrypoints: Vec<PathBuf>,
    /// Optional plugin contracts supplied without reading entrypoint files.
    pub file_entry_contracts: Vec<FileContract>,
    /// Optional file-scoped caller contract applied to the file that requested the plugin.
    pub requesting_file_contract: FileContract,
}

/// Resolver for zsh plugin loads discovered during semantic analysis.
pub trait PluginResolver {
    /// Returns additional configured plugin requests for `source_path`.
    fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
        Vec::new()
    }

    /// Resolves a zsh `source` candidate through plugin/framework roots.
    fn resolve_source_path(&self, _source_path: &Path, _candidate: &str) -> Vec<PathBuf> {
        Vec::new()
    }

    /// Resolves one plugin request into entrypoint paths and optional contracts.
    fn resolve_plugin_request(
        &self,
        source_path: &Path,
        request: &PluginRequest,
    ) -> PluginResolution;
}

fn dedup_synthetic_reads(reads: Vec<SyntheticRead>) -> Vec<SyntheticRead> {
    let mut seen = FxHashSet::default();
    let mut deduped = Vec::new();
    for read in reads {
        if seen.insert((read.scope, read.span.start.offset(), read.name.clone())) {
            deduped.push(read);
        }
    }
    deduped
}

fn assignment_like_binding(kind: BindingKind) -> bool {
    matches!(
        kind,
        BindingKind::Assignment
            | BindingKind::AppendAssignment
            | BindingKind::ArrayAssignment
            | BindingKind::ArithmeticAssignment
    )
}

fn binding_blocks_same_scope_assoc_lookup(binding: &Binding) -> bool {
    binding.attributes.contains(BindingAttributes::LOCAL) || !assignment_like_binding(binding.kind)
}

fn previous_visible_binding_id_from_slice(
    all_bindings: &[Binding],
    bindings: &[BindingId],
    offset: usize,
    ignored_binding_span: Option<Span>,
) -> Option<BindingId> {
    let candidate_count = bindings.partition_point(|binding_id| {
        all_bindings[binding_id.index()].span.start.offset() <= offset
    });

    bindings[..candidate_count]
        .iter()
        .rev()
        .copied()
        .find(|binding_id| ignored_binding_span != Some(all_bindings[binding_id.index()].span))
}

trait BindingIdCollection {
    fn as_slice(&self) -> &[BindingId];
    fn insert_binding_id(&mut self, index: usize, id: BindingId);
}

impl BindingIdCollection for Vec<BindingId> {
    fn as_slice(&self) -> &[BindingId] {
        self
    }

    fn insert_binding_id(&mut self, index: usize, id: BindingId) {
        self.insert(index, id);
    }
}

impl<A> BindingIdCollection for SmallVec<A>
where
    A: Array<Item = BindingId>,
{
    fn as_slice(&self) -> &[BindingId] {
        self
    }

    fn insert_binding_id(&mut self, index: usize, id: BindingId) {
        self.insert(index, id);
    }
}

fn insert_binding_id_sorted(
    bindings: &mut impl BindingIdCollection,
    all_bindings: &[Binding],
    id: BindingId,
) {
    let target = &all_bindings[id.index()];
    let insertion = bindings.as_slice().partition_point(|candidate_id| {
        let candidate = &all_bindings[candidate_id.index()];
        candidate.span.start.offset() < target.span.start.offset()
            || (candidate.span.start.offset() == target.span.start.offset()
                && candidate.span.end.offset() < target.span.end.offset())
            || (candidate.span.start.offset() == target.span.start.offset()
                && candidate.span.end.offset() == target.span.end.offset()
                && candidate.id.index() < target.id.index())
    });
    bindings.insert_binding_id(insertion, id);
}

#[derive(Debug)]
struct AssocLookupBindingIndex {
    blocking_bindings_by_scope: Vec<FxHashMap<Name, Box<[BindingId]>>>,
}

#[derive(Debug)]
struct ScopeProvidedBindingIndex {
    provided_bindings_by_scope: Vec<Box<[ProvidedBinding]>>,
    definite_provider_scopes_by_name: FxHashMap<Name, Box<[ScopeId]>>,
}

#[derive(Debug)]
struct ScopeLookup {
    children: Vec<Box<[ScopeId]>>,
}

impl ScopeLookup {
    fn new(scopes: &[Scope]) -> Self {
        let mut children = vec![Vec::new(); scopes.len()];

        for scope in scopes {
            if let Some(parent) = scope.parent {
                children[parent.index()].push(scope.id);
            }
        }

        for scope_ids in &mut children {
            scope_ids.sort_by_key(|scope_id| {
                let span = scopes[scope_id.index()].span;
                (span.start.offset(), span.end.offset())
            });
        }

        Self {
            children: children.into_iter().map(Vec::into_boxed_slice).collect(),
        }
    }

    fn scope_at(&self, scopes: &[Scope], offset: usize) -> Option<ScopeId> {
        let root = scopes.first()?;
        if !contains_offset(root.span, offset) {
            return None;
        }

        let mut scope = root.id;
        while let Some(child) = self.child_scope_at(scopes, scope, offset) {
            scope = child;
        }

        Some(scope)
    }

    fn child_scope_at(&self, scopes: &[Scope], parent: ScopeId, offset: usize) -> Option<ScopeId> {
        let children = self.children.get(parent.index())?;
        let cutoff = children
            .partition_point(|scope_id| scopes[scope_id.index()].span.start.offset() <= offset);
        let mut best: Option<ScopeId> = None;
        let mut index = cutoff;

        while index > 0 {
            index -= 1;
            let scope_id = children[index];
            let span = scopes[scope_id.index()].span;
            if span.end.offset() < offset {
                break;
            }
            if contains_offset(span, offset) {
                match best {
                    Some(current)
                        if scope_span_width(scopes[current.index()].span)
                            <= scope_span_width(span) => {}
                    _ => best = Some(scope_id),
                }
            }
        }

        best
    }
}

/// Semantic model constructed from a parsed shell file and source text.
#[derive(Debug)]
pub struct SemanticModel {
    shell_profile: ShellProfile,
    scopes: Vec<Scope>,
    scope_lookup: ScopeLookup,
    scope_at_segments: OnceLock<Vec<(usize, ScopeId)>>,
    bindings: Vec<Binding>,
    references: Vec<Reference>,
    reference_index: FxHashMap<Name, SmallVec<[ReferenceId; 2]>>,
    predefined_runtime_refs: FxHashSet<ReferenceId>,
    guarded_parameter_refs: FxHashSet<ReferenceId>,
    parameter_guard_flow_refs: FxHashSet<ReferenceId>,
    defaulting_parameter_operand_refs: FxHashSet<ReferenceId>,
    self_referential_assignment_refs: FxHashSet<ReferenceId>,
    binding_index: FxHashMap<Name, SmallVec<[BindingId; 2]>>,
    resolved: FxHashMap<ReferenceId, BindingId>,
    unresolved: Vec<ReferenceId>,
    functions: FxHashMap<Name, SmallVec<[BindingId; 2]>>,
    call_sites: FxHashMap<Name, SmallVec<[CallSite; 2]>>,
    call_graph: OnceLock<CallGraph>,
    source_refs: Vec<SourceRef>,
    source_path_templates_by_binding: FxHashMap<BindingId, source_closure::SourcePathTemplate>,
    runtime: RuntimePrelude,
    declarations: Vec<Declaration>,
    indirect_target_hints: FxHashMap<BindingId, IndirectTargetHint>,
    indirect_targets_by_binding: FxHashMap<BindingId, Vec<BindingId>>,
    indirect_targets_by_reference: FxHashMap<ReferenceId, Vec<BindingId>>,
    array_like_indirect_expansion_refs: FxHashSet<ReferenceId>,
    synthetic_reads: Vec<SyntheticRead>,
    entry_bindings: Vec<BindingId>,
    flow_contexts: Vec<(Span, FlowContext)>,
    recorded_program: RecordedProgram,
    command_bindings: FxHashMap<SpanKey, SmallVec<[BindingId; 2]>>,
    command_references: FxHashMap<SpanKey, SmallVec<[ReferenceId; 4]>>,
    cleared_variables: FxHashMap<(ScopeId, Name), SmallVec<[usize; 2]>>,
    import_origins_by_binding: FxHashMap<BindingId, Vec<PathBuf>>,
    imported_dependency_paths: Vec<PathBuf>,
    heuristic_unused_assignments: Vec<BindingId>,
    zsh_option_analysis: OnceLock<Option<ZshOptionAnalysis>>,
    zsh_runtime_ambiguous_entry_mask: OnceLock<zsh_options::ZshOptionMask>,
    zsh_runtime_by_function: OnceLock<FxHashMap<ScopeId, OnceLock<Option<ZshOptionAnalysis>>>>,
    zsh_runtime_function_summaries: OnceLock<zsh_options::SharedFunctionSummaryCache>,
    assoc_lookup_binding_index: OnceLock<AssocLookupBindingIndex>,
    command_topology: OnceLock<CommandTopology>,
    references_sorted_by_start: OnceLock<Vec<ReferenceId>>,
    bindings_sorted_by_start: OnceLock<Vec<BindingId>>,
    bindings_by_definition_span: OnceLock<FxHashMap<SpanKey, BindingId>>,
    guarded_or_defaulting_reference_offsets_by_name: OnceLock<FxHashMap<Name, Box<[usize]>>>,
    declarations_by_command_span: OnceLock<FxHashMap<SpanKey, usize>>,
    editor_document_symbol_ranges_by_binding: OnceLock<Vec<Option<Span>>>,
    editor_hover_targets: OnceLock<editor::EditorHoverTargets>,
    unconditional_function_bindings: OnceLock<FxHashSet<BindingId>>,
    function_bindings_by_scope: OnceLock<FxHashMap<ScopeId, SmallVec<[BindingId; 2]>>>,
    visible_function_call_bindings: OnceLock<FxHashMap<SpanKey, BindingId>>,
    function_definition_binding_ids: OnceLock<Vec<BindingId>>,
    array_use_index: OnceLock<array_use::ArrayUseIndex>,
}

/// Lazy analysis view over a `SemanticModel`.
#[derive(Debug)]
pub struct SemanticAnalysis<'model> {
    model: &'model SemanticModel,
    cfg: OnceLock<ControlFlowGraph>,
    exact_variable_dataflow: OnceLock<ExactVariableDataflow>,
    #[cfg(test)]
    dataflow: OnceLock<DataflowResult>,
    unused_assignments: OnceLock<Vec<BindingId>>,
    unused_assignments_shellcheck_compat: OnceLock<Vec<BindingId>>,
    uninitialized_references: OnceLock<Vec<UninitializedReference>>,
    uninitialized_reference_certainties: OnceLock<FxHashMap<SpanKey, UninitializedCertainty>>,
    dead_code: OnceLock<Vec<DeadCode>>,
    unreachable_blocks: OnceLock<FxHashSet<BlockId>>,
    binding_block_index: OnceLock<Vec<Vec<BlockId>>>,
    overwritten_functions: OnceLock<Vec<OverwrittenFunction>>,
    unreached_functions: OnceLock<Vec<UnreachedFunction>>,
    unreached_functions_shellcheck_compat: OnceLock<Vec<UnreachedFunction>>,
    scope_provided_binding_index: OnceLock<ScopeProvidedBindingIndex>,
}

impl SemanticModel {
    /// Builds a semantic model for `file` using the default build options.
    pub fn build(file: &File, source: &str, indexer: &Indexer) -> Self {
        Self::build_with_options(file, source, indexer, SemanticBuildOptions::default())
    }

    /// Builds a semantic model for `file` using explicit semantic build options.
    pub fn build_with_options(
        file: &File,
        source: &str,
        indexer: &Indexer,
        options: SemanticBuildOptions<'_>,
    ) -> Self {
        let mut observer = NoopTraversalObserver;
        build_with_observer_with_options(file, source, indexer, &mut observer, options)
    }

    fn from_build_output(built: builder::BuildOutput) -> Self {
        let mut reference_index = built.reference_index;
        for reference_ids in reference_index.values_mut() {
            reference_ids.sort_by_key(|reference_id| {
                built.references[reference_id.index()].span.start.offset()
            });
        }
        let indirect_targets_by_binding =
            build_indirect_targets_by_binding(&built.bindings, &built.indirect_target_hints);
        let indirect_targets_by_reference = build_indirect_targets_by_reference(
            &built.references,
            &built.resolved,
            &built.indirect_expansion_refs,
            &indirect_targets_by_binding,
        );
        let array_like_indirect_expansion_refs = build_array_like_indirect_expansion_refs(
            &built.references,
            &built.resolved,
            &built.indirect_expansion_refs,
            &built.indirect_target_hints,
        );
        let scope_lookup = ScopeLookup::new(&built.scopes);
        Self {
            shell_profile: built.shell_profile,
            scopes: built.scopes,
            scope_lookup,
            scope_at_segments: OnceLock::new(),
            bindings: built.bindings,
            references: built.references,
            reference_index,
            predefined_runtime_refs: built.predefined_runtime_refs,
            guarded_parameter_refs: built.guarded_parameter_refs,
            parameter_guard_flow_refs: built.parameter_guard_flow_refs,
            defaulting_parameter_operand_refs: built.defaulting_parameter_operand_refs,
            self_referential_assignment_refs: built.self_referential_assignment_refs,
            binding_index: built.binding_index,
            resolved: built.resolved,
            unresolved: built.unresolved,
            functions: built.functions,
            call_sites: built.call_sites,
            call_graph: OnceLock::new(),
            source_refs: built.source_refs,
            source_path_templates_by_binding: built.source_path_templates_by_binding,
            runtime: built.runtime,
            declarations: built.declarations,
            indirect_target_hints: built.indirect_target_hints,
            indirect_targets_by_binding,
            indirect_targets_by_reference,
            array_like_indirect_expansion_refs,
            synthetic_reads: Vec::new(),
            entry_bindings: Vec::new(),
            flow_contexts: built.flow_contexts,
            recorded_program: built.recorded_program,
            command_bindings: built.command_bindings,
            command_references: built.command_references,
            cleared_variables: built.cleared_variables,
            import_origins_by_binding: FxHashMap::default(),
            imported_dependency_paths: Vec::new(),
            heuristic_unused_assignments: built.heuristic_unused_assignments,
            zsh_option_analysis: OnceLock::new(),
            zsh_runtime_ambiguous_entry_mask: OnceLock::new(),
            zsh_runtime_by_function: OnceLock::new(),
            zsh_runtime_function_summaries: OnceLock::new(),
            assoc_lookup_binding_index: OnceLock::new(),
            command_topology: OnceLock::new(),
            references_sorted_by_start: OnceLock::new(),
            bindings_sorted_by_start: OnceLock::new(),
            bindings_by_definition_span: OnceLock::new(),
            guarded_or_defaulting_reference_offsets_by_name: OnceLock::new(),
            declarations_by_command_span: OnceLock::new(),
            editor_document_symbol_ranges_by_binding: OnceLock::new(),
            editor_hover_targets: OnceLock::new(),
            unconditional_function_bindings: OnceLock::new(),
            function_bindings_by_scope: OnceLock::new(),
            visible_function_call_bindings: OnceLock::new(),
            function_definition_binding_ids: OnceLock::new(),
            array_use_index: OnceLock::new(),
        }
    }

    /// Returns a lazy analysis view that computes CFG, dataflow, and reachability on demand.
    pub fn analysis(&self) -> SemanticAnalysis<'_> {
        SemanticAnalysis::new(self)
    }

    /// Returns the shell profile used when building this model.
    pub fn shell_profile(&self) -> &ShellProfile {
        &self.shell_profile
    }

    /// Returns the zsh option state visible at `offset`, when the model tracks zsh options.
    pub fn zsh_options_at(&self, offset: usize) -> Option<&ZshOptionState> {
        self.zsh_option_analysis()
            .and_then(|analysis| analysis.options_at(&self.scopes, offset))
    }

    /// Returns whether `offset` is in a definite zsh `emulate sh` region.
    pub fn zsh_sh_emulation_at(&self, offset: usize) -> Option<bool> {
        self.zsh_option_analysis()
            .and_then(|analysis| analysis.sh_emulation_at(&self.scopes, offset))
    }

    /// Returns option-sensitive shell behavior visible at `offset`.
    pub fn shell_behavior_at(&self, offset: usize) -> ShellBehaviorAt<'_> {
        ShellBehaviorAt {
            shell: self.shell_profile.dialect,
            zsh_options: self.zsh_options_at(offset),
            runtime_options: self.zsh_runtime_options_at(offset),
            zsh_sh_emulation: self.zsh_sh_emulation_at(offset),
        }
    }

    fn zsh_runtime_ambiguous_entry_mask(&self) -> zsh_options::ZshOptionMask {
        if self.shell_profile.zsh_options().is_none() {
            return zsh_options::ZshOptionMask::default();
        }

        *self.zsh_runtime_ambiguous_entry_mask.get_or_init(|| {
            crate::zsh_options::runtime_ambiguous_entry_mask(&self.recorded_program)
        })
    }

    fn zsh_runtime_options_at(&self, offset: usize) -> Option<ZshOptionState> {
        self.shell_profile.zsh_options()?;
        let ordinary = *self.zsh_options_at(offset)?;
        let ambiguous_entry = self.zsh_runtime_ambiguous_entry_mask();
        if ambiguous_entry.is_empty() {
            return None;
        }

        let scope = self.scope_at(offset);
        let function_scope = self.enclosing_function_scope(scope)?;
        let ambient = self
            .zsh_runtime_analysis_for_function(function_scope)
            .and_then(|analysis| analysis.options_at(&self.scopes, offset));

        Some(ambient.map_or(ordinary, |options| ordinary.merge(options)))
    }

    fn zsh_option_analysis(&self) -> Option<&ZshOptionAnalysis> {
        self.zsh_option_analysis
            .get_or_init(|| self.build_zsh_option_analysis())
            .as_ref()
    }

    fn build_zsh_option_analysis(&self) -> Option<ZshOptionAnalysis> {
        let zsh_dynamic_calls = zsh_options::DynamicCallAnalysisContext {
            references: &self.references,
            resolved: &self.resolved,
            indirect_target_hints: &self.indirect_target_hints,
            indirect_targets_by_binding: &self.indirect_targets_by_binding,
            command_references: &self.command_references,
        };
        zsh_options::analyze(
            &self.shell_profile,
            &self.scopes,
            &self.bindings,
            &self.recorded_program,
            zsh_dynamic_calls,
        )
    }

    fn zsh_runtime_by_function(&self) -> &FxHashMap<ScopeId, OnceLock<Option<ZshOptionAnalysis>>> {
        self.zsh_runtime_by_function.get_or_init(|| {
            self.recorded_program
                .function_bodies()
                .keys()
                .map(|&function_scope| (function_scope, OnceLock::new()))
                .collect()
        })
    }

    fn zsh_runtime_analysis_for_function(
        &self,
        function_scope: ScopeId,
    ) -> Option<&ZshOptionAnalysis> {
        self.zsh_runtime_by_function()
            .get(&function_scope)?
            .get_or_init(|| self.build_zsh_runtime_analysis_for_function(function_scope))
            .as_ref()
    }

    fn build_zsh_runtime_analysis_for_function(
        &self,
        function_scope: ScopeId,
    ) -> Option<ZshOptionAnalysis> {
        let function_entry_offset = self.scope(function_scope).span.start.offset();
        let mut function_entry = *self.zsh_options_at(function_entry_offset)?;
        for field in self.zsh_runtime_ambiguous_entry_mask().iter() {
            crate::zsh_options::set_public_option_field(
                &mut function_entry,
                field,
                OptionValue::Unknown,
            );
        }
        crate::zsh_options::function_runtime_analysis_with_entry(
            &self.scopes,
            &self.bindings,
            &self.recorded_program,
            crate::zsh_options::DynamicCallAnalysisContext {
                references: &self.references,
                resolved: &self.resolved,
                indirect_target_hints: &self.indirect_target_hints,
                indirect_targets_by_binding: &self.indirect_targets_by_binding,
                command_references: &self.command_references,
            },
            Some(
                self.zsh_runtime_function_summaries
                    .get_or_init(Default::default),
            ),
            function_scope,
            function_entry,
        )
    }

    /// Returns all semantic scopes discovered in the file.
    pub fn scopes(&self) -> &[Scope] {
        &self.scopes
    }

    /// Returns the scope identified by `id`.
    pub fn scope(&self, id: ScopeId) -> &Scope {
        &self.scopes[id.index()]
    }

    /// Returns all semantic bindings discovered in the file.
    pub fn bindings(&self) -> &[Binding] {
        &self.bindings
    }

    /// Yield every binding with `BindingKind::FunctionDefinition`.
    ///
    /// Backed by a lazily-built index so repeat calls avoid rescanning the
    /// full `bindings()` slice.
    pub fn function_definition_bindings(&self) -> impl ExactSizeIterator<Item = &Binding> + '_ {
        let ids = self.function_definition_binding_ids.get_or_init(|| {
            self.bindings
                .iter()
                .filter(|binding| matches!(binding.kind, BindingKind::FunctionDefinition))
                .map(|binding| binding.id)
                .collect()
        });
        ids.iter().map(|id| &self.bindings[id.index()])
    }

    /// Returns all semantic references discovered in the file.
    pub fn references(&self) -> &[Reference] {
        &self.references
    }

    /// Returns guarded or defaulting reference offsets grouped by variable name.
    ///
    /// Backed by a lazily-built summary so repeated undefined-variable suppression
    /// checks can reuse the same grouped offsets instead of rescanning `references()`.
    pub fn guarded_or_defaulting_reference_offsets_by_name(
        &self,
    ) -> &FxHashMap<Name, Box<[usize]>> {
        self.guarded_or_defaulting_reference_offsets_by_name
            .get_or_init(|| {
                build_guarded_or_defaulting_reference_offsets_by_name(
                    &self.references,
                    &self.guarded_parameter_refs,
                    &self.defaulting_parameter_operand_refs,
                )
            })
    }

    /// Summarize unguarded positional-parameter reads by enclosing function scope.
    ///
    /// Callers can pass transient-scope `set --` offsets to exclude reads that are
    /// masked by a nested local positional reset.
    pub fn function_positional_reference_summary(
        &self,
        local_reset_offsets_by_scope: &FxHashMap<ScopeId, Vec<usize>>,
    ) -> FxHashMap<ScopeId, FunctionPositionalReferenceSummary> {
        let mut summaries = FxHashMap::<ScopeId, FunctionPositionalReferenceSummary>::default();

        for (name, reference_ids) in &self.reference_index {
            let Some(kind) = positional_parameter_reference_kind(name.as_str()) else {
                continue;
            };

            for reference_id in reference_ids {
                let reference = &self.references[reference_id.index()];
                if self.is_guarded_parameter_reference(reference.id)
                    || reference_has_local_positional_reset(
                        self,
                        reference.scope,
                        reference.span.start.offset(),
                        local_reset_offsets_by_scope,
                    )
                {
                    continue;
                }

                let Some(function_scope) = self.enclosing_function_scope(reference.scope) else {
                    continue;
                };

                let entry = summaries.entry(function_scope).or_default();
                match kind {
                    PositionalParameterReferenceKind::Indexed(index) => {
                        entry.record_required_arg_count(index);
                    }
                    PositionalParameterReferenceKind::Special => entry.record_use(),
                }
            }
        }

        summaries
    }

    /// Yield every reference whose span is fully contained within `outer`.
    ///
    /// Backed by a lazily-built index sorted by reference start offset, so a
    /// per-span query costs `O(log n + matches)` rather than scanning every
    /// reference in the file.
    pub fn references_in_span(&self, outer: Span) -> ReferencesInSpan<'_> {
        let sorted = self
            .references_sorted_by_start
            .get_or_init(|| build_references_sorted_by_start(&self.references));
        let lower = sorted.partition_point(|id| {
            self.references[id.index()].span.start.offset() < outer.start.offset()
        });
        ReferencesInSpan {
            references: &self.references,
            ids: sorted[lower..].iter(),
            end: outer.end.offset(),
        }
    }

    /// Yield direct references for `command_span` whose spans are fully contained within `outer`.
    ///
    /// References recorded for nested commands are excluded because semantic
    /// stores them against their own command spans instead of the enclosing
    /// command.
    pub fn references_in_command_span(
        &self,
        command_span: Span,
        outer: Span,
    ) -> CommandReferencesInSpan<'_> {
        let command_span = self
            .command_references
            .contains_key(&SpanKey::new(command_span))
            .then_some(command_span)
            .or_else(|| {
                self.command_by_span(command_span)
                    .map(|id| self.command_span(id))
            });
        let ids = command_span
            .filter(|span| contains_span(*span, outer))
            .and_then(|span| self.command_references.get(&SpanKey::new(span)))
            .map(SmallVec::as_slice)
            .unwrap_or(&[]);
        CommandReferencesInSpan {
            references: &self.references,
            ids: ids.iter(),
            outer,
        }
    }

    /// Yield every binding whose span is fully contained within `outer`.
    ///
    /// Backed by a lazily-built index sorted by binding start offset, so a
    /// per-span query costs `O(log n + matches)` rather than scanning every
    /// binding in the file.
    pub fn bindings_in_span(&self, outer: Span) -> BindingsInSpan<'_> {
        let sorted = self
            .bindings_sorted_by_start
            .get_or_init(|| build_bindings_sorted_by_start(&self.bindings));
        let lower = sorted.partition_point(|id| {
            self.bindings[id.index()].span.start.offset() < outer.start.offset()
        });
        BindingsInSpan {
            bindings: &self.bindings,
            ids: sorted[lower..].iter(),
            end: outer.end.offset(),
        }
    }

    /// Returns the binding identified by `id`.
    pub fn binding(&self, id: BindingId) -> &Binding {
        &self.bindings[id.index()]
    }

    /// Returns the binding introduced at exactly `span`, when such a definition is indexed.
    pub fn binding_for_definition_span(&self, span: Span) -> Option<BindingId> {
        let index = self
            .bindings_by_definition_span
            .get_or_init(|| build_bindings_by_definition_span(&self.bindings));
        index.get(&SpanKey::new(span)).copied()
    }

    /// Returns the reference identified by `id`.
    pub fn reference(&self, id: ReferenceId) -> &Reference {
        &self.references[id.index()]
    }

    /// Returns the binding resolved for `id`, if reference resolution succeeded.
    pub fn resolved_binding(&self, id: ReferenceId) -> Option<&Binding> {
        self.resolved
            .get(&id)
            .map(|binding| &self.bindings[binding.index()])
    }

    /// Returns whether `id` names a predefined runtime array variable.
    pub fn reference_is_predefined_runtime_array(&self, id: ReferenceId) -> bool {
        self.predefined_runtime_refs.contains(&id)
            && self
                .references
                .get(id.index())
                .is_some_and(|reference| self.runtime.is_preinitialized_array(&reference.name))
    }

    /// Returns whether `name` is provided by the shell runtime for this model's dialect.
    pub fn name_is_predefined_runtime(&self, name: &str) -> bool {
        self.runtime.is_preinitialized(&Name::from(name))
    }

    /// Returns whether `name` is a well-known runtime-style name that typo suppression should
    /// ignore for this model's shell dialect.
    pub fn name_is_known_runtime(&self, name: &str) -> bool {
        self.runtime.is_known_runtime_name(&Name::from(name))
    }

    /// Returns whether `id` is guarded by parameter-expansion syntax that suppresses missing-name
    /// diagnostics.
    pub fn is_guarded_parameter_reference(&self, id: ReferenceId) -> bool {
        self.guarded_parameter_refs.contains(&id)
    }

    /// Returns whether `id` appears inside a defaulting parameter operand.
    pub fn is_defaulting_parameter_operand_reference(&self, id: ReferenceId) -> bool {
        self.defaulting_parameter_operand_refs.contains(&id)
    }

    /// Returns bindings that may be targeted by indirect reads through `id`.
    pub fn indirect_targets_for_binding(&self, id: BindingId) -> &[BindingId] {
        self.indirect_targets_by_binding
            .get(&id)
            .map(Vec::as_slice)
            .unwrap_or(&[])
    }

    /// Returns bindings that may be targeted by the indirect reference `id`.
    pub fn indirect_targets_for_reference(&self, id: ReferenceId) -> &[BindingId] {
        self.indirect_targets_by_reference
            .get(&id)
            .map(Vec::as_slice)
            .unwrap_or(&[])
    }

    /// Returns bindings recorded for `name`, ordered by definition offset.
    pub fn bindings_for(&self, name: &Name) -> &[BindingId] {
        self.binding_index
            .get(name)
            .map(SmallVec::as_slice)
            .unwrap_or(&[])
    }

    /// Returns the latest binding for `name` that is visible at `at`.
    pub fn visible_binding(&self, name: &Name, at: Span) -> Option<&Binding> {
        self.previous_visible_binding(name, at, None)
    }

    /// Return binding candidates for a reference using lexical visibility first,
    /// then prior same-name bindings outside the reference scope.
    pub fn visible_candidate_bindings_for_reference(
        &self,
        reference: &Reference,
    ) -> Vec<BindingId> {
        let all_bindings = self.bindings_for(&reference.name);
        let binding_ids = self
            .ancestor_scopes(reference.scope)
            .filter_map(|scope| {
                all_bindings.iter().copied().rev().find(|binding_id| {
                    let binding = self.binding(*binding_id);
                    binding.scope == scope && self.binding_visible_at(*binding_id, reference.span)
                })
            })
            .collect::<Vec<_>>();
        if !binding_ids.is_empty() {
            return binding_ids;
        }

        self.ancestor_scopes(reference.scope)
            .skip(1)
            .filter_map(|scope| {
                all_bindings.iter().copied().rev().find(|binding_id| {
                    let binding = self.binding(*binding_id);
                    binding.scope == scope && self.binding_visible_at(*binding_id, reference.span)
                })
            })
            .chain(all_bindings.iter().copied().filter(|binding_id| {
                let binding = self.binding(*binding_id);
                binding.scope != reference.scope
                    && binding.span.start.offset() < reference.span.start.offset()
            }))
            .collect::<FxHashSet<_>>()
            .into_iter()
            .collect::<Vec<_>>()
    }

    #[doc(hidden)]
    /// Returns whether `binding_id` is lexically visible at `at`.
    #[doc(hidden)]
    pub fn binding_visible_at(&self, binding_id: BindingId, at: Span) -> bool {
        let binding = self.binding(binding_id);
        binding.span.start.offset() <= at.start.offset()
            && self
                .ancestor_scopes(self.scope_at(at.start.offset()))
                .any(|scope| scope == binding.scope)
    }

    /// Returns whether `binding_id` is cleared between its definition and `at`.
    #[doc(hidden)]
    pub fn binding_cleared_before(&self, binding_id: BindingId, at: Span) -> bool {
        let binding = self.binding(binding_id);
        self.cleared_variables
            .get(&(binding.scope, binding.name.clone()))
            .is_some_and(|cleared_offsets| {
                cleared_offsets.iter().any(|cleared_offset| {
                    *cleared_offset > binding.span.start.offset()
                        && *cleared_offset < at.start.offset()
                })
            })
    }

    /// Returns the previous visible binding for `name` at `at`, optionally ignoring one exact
    /// binding span.
    #[doc(hidden)]
    pub fn previous_visible_binding(
        &self,
        name: &Name,
        at: Span,
        ignored_binding_span: Option<Span>,
    ) -> Option<&Binding> {
        let scope = self.scope_at(at.start.offset());
        self.previous_visible_binding_id_in_scope_chain(
            name,
            scope,
            at.start.offset(),
            ignored_binding_span,
        )
        .map(|binding_id| self.binding(binding_id))
    }

    /// Returns the binding visible for an associative-array lookup in `current_scope`.
    #[doc(hidden)]
    pub fn visible_binding_for_assoc_lookup(
        &self,
        name: &Name,
        current_scope: ScopeId,
        at: Span,
    ) -> Option<&Binding> {
        if let Some(binding_id) =
            self.previous_assoc_lookup_binding_id_in_scope(current_scope, name, at.start.offset())
        {
            return Some(self.binding(binding_id));
        }

        self.ancestor_scopes(current_scope)
            .skip(1)
            .find_map(|scope| {
                self.previous_visible_binding_id_in_scope(scope, name, at.start.offset(), None)
            })
            .map(|binding_id| self.binding(binding_id))
    }

    /// Returns a visible binding for a contextual lookup, including named callers.
    #[doc(hidden)]
    pub fn visible_binding_for_lookup(
        &self,
        name: &Name,
        current_scope: ScopeId,
        at: Span,
    ) -> Option<&Binding> {
        if let Some(binding_id) = self.previous_visible_binding_id_in_scope_chain(
            name,
            current_scope,
            at.start.offset(),
            None,
        ) {
            return Some(self.binding(binding_id));
        }

        self.visible_binding_from_named_callers(name, current_scope)
    }

    /// Returns a visible binding that decides contextual associative lookups.
    #[doc(hidden)]
    pub fn visible_assoc_lookup_binding_for_lookup(
        &self,
        name: &Name,
        current_scope: ScopeId,
        at: Span,
    ) -> Option<&Binding> {
        if let Some(binding) = self.visible_binding_for_assoc_lookup(name, current_scope, at) {
            return Some(binding);
        }

        self.visible_assoc_lookup_binding_from_named_callers(name, current_scope)
    }

    /// Returns whether an associative binding is visible for a contextual array lookup.
    pub fn assoc_binding_visible_for_lookup(
        &self,
        name: &Name,
        current_scope: ScopeId,
        at: Span,
    ) -> bool {
        if let Some(visible) = self.assoc_binding_visible_in_scope(name, current_scope, at) {
            return visible;
        }

        self.assoc_binding_visible_from_named_callers(name, current_scope)
    }

    fn assoc_binding_visible_in_scope(
        &self,
        name: &Name,
        current_scope: ScopeId,
        at: Span,
    ) -> Option<bool> {
        self.visible_binding_for_assoc_lookup(name, current_scope, at)
            .map(|binding| binding.attributes.contains(BindingAttributes::ASSOC))
    }

    fn assoc_binding_visible_from_named_callers(
        &self,
        name: &Name,
        current_scope: ScopeId,
    ) -> bool {
        let Some(function_names) = self.named_function_scope_names(current_scope) else {
            return false;
        };

        let mut seen = AssocCallerSeenNames::new();
        let mut worklist = SmallVec::<[Name; 4]>::new();
        worklist.extend(function_names.iter().cloned());

        while let Some(function_name) = worklist.pop() {
            if !seen.insert(&function_name) {
                continue;
            }

            for call_site in self.call_sites_for(&function_name) {
                if let Some(binding) = self.visible_binding_for_assoc_lookup(
                    name,
                    call_site.scope,
                    call_site.name_span,
                ) {
                    if binding.attributes.contains(BindingAttributes::ASSOC) {
                        return true;
                    }
                    continue;
                }

                if let Some(caller_names) = self.named_function_scope_names(call_site.scope) {
                    worklist.extend(caller_names.iter().cloned());
                }
            }
        }

        false
    }

    fn visible_assoc_lookup_binding_from_named_callers(
        &self,
        name: &Name,
        current_scope: ScopeId,
    ) -> Option<&Binding> {
        let function_names = self.named_function_scope_names(current_scope)?;

        let mut seen = AssocCallerSeenNames::new();
        let mut worklist = SmallVec::<[Name; 4]>::new();
        worklist.extend(function_names.iter().cloned());

        while let Some(function_name) = worklist.pop() {
            if !seen.insert(&function_name) {
                continue;
            }

            for call_site in self.call_sites_for(&function_name) {
                if let Some(binding) = self.visible_binding_for_assoc_lookup(
                    name,
                    call_site.scope,
                    call_site.name_span,
                ) {
                    return Some(binding);
                }

                if let Some(caller_names) = self.named_function_scope_names(call_site.scope) {
                    worklist.extend(caller_names.iter().cloned());
                }
            }
        }

        None
    }

    fn visible_binding_from_named_callers(
        &self,
        name: &Name,
        current_scope: ScopeId,
    ) -> Option<&Binding> {
        let function_names = self.named_function_scope_names(current_scope)?;

        let mut seen = AssocCallerSeenNames::new();
        let mut worklist = SmallVec::<[Name; 4]>::new();
        worklist.extend(function_names.iter().cloned());

        while let Some(function_name) = worklist.pop() {
            if !seen.insert(&function_name) {
                continue;
            }

            for call_site in self.call_sites_for(&function_name) {
                if let Some(binding_id) = self.previous_visible_binding_id_in_scope_chain(
                    name,
                    call_site.scope,
                    call_site.name_span.start.offset(),
                    None,
                ) {
                    return Some(self.binding(binding_id));
                }

                if let Some(caller_names) = self.named_function_scope_names(call_site.scope) {
                    worklist.extend(caller_names.iter().cloned());
                }
            }
        }

        None
    }

    fn named_function_scope_names(&self, scope: ScopeId) -> Option<&[Name]> {
        self.ancestor_scopes(scope)
            .find_map(|scope_id| match &self.scope(scope_id).kind {
                ScopeKind::Function(FunctionScopeKind::Named(names)) => Some(names.as_slice()),
                _ => None,
            })
    }

    /// Returns whether runtime behavior can consume `binding_id` even without a direct read in
    /// the current file.
    pub fn is_runtime_consumed_binding(&self, binding_id: BindingId) -> bool {
        self.bindings
            .get(binding_id.index())
            .is_some_and(|binding| self.runtime.is_always_used_binding(&binding.name))
    }

    /// Returns references that did not resolve to any binding.
    pub fn unresolved_references(&self) -> &[ReferenceId] {
        &self.unresolved
    }

    /// Returns the innermost semantic scope containing `offset`.
    pub fn scope_at(&self, offset: usize) -> ScopeId {
        let segments = self
            .scope_at_segments
            .get_or_init(|| build_scope_at_segments(&self.scopes, &self.scope_lookup));
        let index = segments.partition_point(|(start, _)| *start <= offset);
        index
            .checked_sub(1)
            .map_or(ScopeId(0), |index| segments[index].1)
    }

    /// Returns the kind metadata for `scope`.
    pub fn scope_kind(&self, scope: ScopeId) -> &ScopeKind {
        &self.scopes[scope.index()].kind
    }

    fn scope_is_transient(&self, scope: ScopeId) -> bool {
        matches!(
            self.scope_kind(scope),
            ScopeKind::Subshell | ScopeKind::CommandSubstitution | ScopeKind::Pipeline
        )
    }

    /// Iterates `scope` and then each lexical ancestor scope outward.
    pub fn ancestor_scopes(&self, scope: ScopeId) -> impl Iterator<Item = ScopeId> + '_ {
        ancestor_scopes(&self.scopes, scope)
    }

    /// Returns whether `scope` is equal to `ancestor_scope` or nested within it.
    pub fn scope_is_in_scope_or_descendant(&self, scope: ScopeId, ancestor_scope: ScopeId) -> bool {
        self.ancestor_scopes(scope)
            .any(|scope| scope == ancestor_scope)
    }

    /// Returns whether `scope` is strictly nested within `ancestor_scope`.
    pub fn scope_is_descendant_of(&self, scope: ScopeId, ancestor_scope: ScopeId) -> bool {
        scope != ancestor_scope && self.scope_is_in_scope_or_descendant(scope, ancestor_scope)
    }

    /// Returns the nearest enclosing function scope for `scope`, if one exists.
    pub fn enclosing_function_scope(&self, scope: ScopeId) -> Option<ScopeId> {
        enclosing_function_scope(&self.scopes, scope)
    }

    /// Iterates transient ancestor scopes between `scope` and its enclosing function boundary.
    #[doc(hidden)]
    pub fn transient_ancestor_scopes_within_function(
        &self,
        scope: ScopeId,
    ) -> impl Iterator<Item = ScopeId> + '_ {
        self.ancestor_scopes(scope)
            .take_while(|scope_id| !matches!(self.scope_kind(*scope_id), ScopeKind::Function(_)))
            .filter(|scope_id| self.scope_is_transient(*scope_id))
    }

    /// Returns the innermost transient ancestor scope before crossing a function boundary.
    #[doc(hidden)]
    pub fn innermost_transient_scope_within_function(&self, scope: ScopeId) -> Option<ScopeId> {
        self.transient_ancestor_scopes_within_function(scope).next()
    }

    /// Returns the enclosing function scope only when no transient boundary intervenes.
    #[doc(hidden)]
    pub fn enclosing_function_scope_without_transient_boundary(
        &self,
        scope: ScopeId,
    ) -> Option<ScopeId> {
        if self
            .transient_ancestor_scopes_within_function(scope)
            .next()
            .is_some()
        {
            None
        } else {
            self.enclosing_function_scope(scope)
        }
    }

    fn previous_visible_binding_id_in_scope_chain(
        &self,
        name: &Name,
        scope: ScopeId,
        offset: usize,
        ignored_binding_span: Option<Span>,
    ) -> Option<BindingId> {
        self.ancestor_scopes(scope).find_map(|scope_id| {
            self.previous_visible_binding_id_in_scope(scope_id, name, offset, ignored_binding_span)
        })
    }

    fn previous_visible_binding_id_in_scope(
        &self,
        scope: ScopeId,
        name: &Name,
        offset: usize,
        ignored_binding_span: Option<Span>,
    ) -> Option<BindingId> {
        let bindings = self.scopes[scope.index()].bindings.get(name)?;
        previous_visible_binding_id_from_slice(
            &self.bindings,
            bindings,
            offset,
            ignored_binding_span,
        )
    }

    fn previous_assoc_lookup_binding_id_in_scope(
        &self,
        scope: ScopeId,
        name: &Name,
        offset: usize,
    ) -> Option<BindingId> {
        let bindings = self
            .assoc_lookup_binding_index()
            .blocking_bindings_by_scope
            .get(scope.index())
            .and_then(|bindings_by_name| bindings_by_name.get(name))?;
        previous_visible_binding_id_from_slice(&self.bindings, bindings, offset, None)
    }

    fn assoc_lookup_binding_index(&self) -> &AssocLookupBindingIndex {
        self.assoc_lookup_binding_index.get_or_init(|| {
            let blocking_bindings_by_scope = self
                .scopes
                .iter()
                .map(|scope| {
                    let mut bindings_by_name = FxHashMap::default();
                    for (name, bindings) in &scope.bindings {
                        let filtered = bindings
                            .iter()
                            .copied()
                            .filter(|binding_id| {
                                binding_blocks_same_scope_assoc_lookup(
                                    &self.bindings[binding_id.index()],
                                )
                            })
                            .collect::<Vec<_>>();
                        if !filtered.is_empty() {
                            bindings_by_name.insert(name.clone(), filtered.into_boxed_slice());
                        }
                    }
                    bindings_by_name
                })
                .collect();

            AssocLookupBindingIndex {
                blocking_bindings_by_scope,
            }
        })
    }

    /// Returns the most specific recorded flow context associated with `span`.
    pub fn flow_context_at(&self, span: &Span) -> Option<&FlowContext> {
        self.flow_contexts
            .iter()
            .rfind(|(candidate, _)| candidate == span)
            .map(|(_, context)| context)
            .or_else(|| {
                self.flow_contexts
                    .iter()
                    .enumerate()
                    .filter(|(_, (candidate, _))| {
                        contains_span(*candidate, *span) || contains_span(*span, *candidate)
                    })
                    .min_by_key(|(index, (candidate, _))| {
                        (
                            candidate
                                .end
                                .offset()
                                .saturating_sub(candidate.start.offset()),
                            std::cmp::Reverse(*index),
                        )
                    })
                    .map(|(_, (_, context))| context)
            })
    }

    fn add_imported_binding(
        &mut self,
        provided: &ProvidedBinding,
        scope: ScopeId,
        span: Span,
        command_span: Option<Span>,
        origin_paths: Vec<PathBuf>,
        file_entry_contract: bool,
    ) -> BindingId {
        let mut attributes = BindingAttributes::empty();
        if provided.certainty == ContractCertainty::Possible {
            attributes |= BindingAttributes::IMPORTED_POSSIBLE;
        }
        if provided.kind == ProvidedBindingKind::Function {
            attributes |= BindingAttributes::IMPORTED_FUNCTION;
        }
        if file_entry_contract {
            attributes |= BindingAttributes::IMPORTED_FILE_ENTRY;
            if provided.file_entry_initialization == FileEntryBindingInitialization::Initialized {
                attributes |= BindingAttributes::IMPORTED_FILE_ENTRY_INITIALIZED;
            }
        }

        let id = BindingId(self.bindings.len() as u32);
        self.bindings.push(Binding {
            id,
            name: provided.name.clone(),
            kind: BindingKind::Imported,
            origin: BindingOrigin::Imported {
                definition_span: span,
            },
            scope,
            span,
            references: Vec::new(),
            attributes,
        });
        insert_binding_id_sorted(
            self.binding_index.entry(provided.name.clone()).or_default(),
            &self.bindings,
            id,
        );
        insert_binding_id_sorted(
            self.scopes[scope.index()]
                .bindings
                .entry(provided.name.clone())
                .or_default(),
            &self.bindings,
            id,
        );
        if provided.kind == ProvidedBindingKind::Function {
            insert_binding_id_sorted(
                self.functions.entry(provided.name.clone()).or_default(),
                &self.bindings,
                id,
            );
        }
        if let Some(command_span) = command_span {
            self.command_bindings
                .entry(SpanKey::new(command_span))
                .or_default()
                .push(id);
        }
        if !origin_paths.is_empty() {
            self.import_origins_by_binding.insert(id, origin_paths);
        }
        self.bindings_by_definition_span.take();
        id
    }

    pub(crate) fn apply_file_entry_contract(&mut self, contract: FileContract, file: &File) {
        if contract.required_reads.is_empty()
            && contract.provided_bindings.is_empty()
            && contract.provided_functions.is_empty()
            && !contract.externally_consumed_bindings
            && contract.externally_consumed_binding_names.is_empty()
            && contract.externally_consumed_binding_prefixes.is_empty()
        {
            return;
        }

        if contract.externally_consumed_bindings {
            self.mark_file_entry_consumed_bindings();
        }
        if !contract.externally_consumed_binding_names.is_empty() {
            self.mark_file_entry_consumed_binding_names(
                &contract.externally_consumed_binding_names,
            );
        }
        if !contract.externally_consumed_binding_prefixes.is_empty() {
            self.mark_file_entry_consumed_binding_prefixes(
                &contract.externally_consumed_binding_prefixes,
            );
        }

        let mut synthetic_reads = self.synthetic_reads.clone();
        for name in contract.required_reads {
            synthetic_reads.push(SyntheticRead {
                scope: ScopeId(0),
                span: file.span,
                name,
            });
        }

        let entry_span = Span::from_positions(file.span.start, file.span.start);
        let mut entry_bindings = self.entry_bindings.clone();
        let function_origin_paths = contract
            .provided_functions
            .iter()
            .map(|function| (function.name.clone(), function.origin_paths.clone()))
            .collect::<FxHashMap<_, _>>();
        let mut provided_bindings = contract.provided_bindings;
        for function in contract.provided_functions {
            if !provided_bindings.iter().any(|binding| {
                binding.kind == ProvidedBindingKind::Function && binding.name == function.name
            }) {
                provided_bindings.push(ProvidedBinding::new(
                    function.name,
                    ProvidedBindingKind::Function,
                    ContractCertainty::Definite,
                ));
            }
        }
        for binding in &provided_bindings {
            let origin_paths = function_origin_paths
                .get(&binding.name)
                .cloned()
                .unwrap_or_default();
            let id = self.add_imported_binding(
                binding,
                ScopeId(0),
                entry_span,
                None,
                origin_paths,
                true,
            );
            entry_bindings.push(id);
        }

        self.set_synthetic_reads(dedup_synthetic_reads(synthetic_reads));
        self.set_entry_bindings(entry_bindings);
        self.invalidate_function_binding_lookup();
        self.resolve_unresolved_references();
        self.invalidate_semantic_caches();
    }

    fn mark_file_entry_consumed_bindings(&mut self) {
        for binding in &mut self.bindings {
            if file_entry_contract_can_consume_binding(binding) {
                binding.attributes |= BindingAttributes::EXTERNALLY_CONSUMED;
            }
        }
        self.heuristic_unused_assignments.retain(|binding_id| {
            !self.bindings[binding_id.index()]
                .attributes
                .contains(BindingAttributes::EXTERNALLY_CONSUMED)
        });
    }

    fn mark_file_entry_consumed_binding_names(&mut self, names: &[Name]) {
        for binding in &mut self.bindings {
            if file_entry_contract_can_consume_binding(binding) && names.contains(&binding.name) {
                binding.attributes |= BindingAttributes::EXTERNALLY_CONSUMED;
            }
        }
        self.heuristic_unused_assignments.retain(|binding_id| {
            !self.bindings[binding_id.index()]
                .attributes
                .contains(BindingAttributes::EXTERNALLY_CONSUMED)
        });
    }

    fn mark_file_entry_consumed_binding_prefixes(&mut self, prefixes: &[Name]) {
        for binding in &mut self.bindings {
            if file_entry_contract_can_consume_binding(binding)
                && prefixes
                    .iter()
                    .any(|prefix| binding.name.as_str().starts_with(prefix.as_str()))
            {
                binding.attributes |= BindingAttributes::EXTERNALLY_CONSUMED;
            }
        }
        self.heuristic_unused_assignments.retain(|binding_id| {
            !self.bindings[binding_id.index()]
                .attributes
                .contains(BindingAttributes::EXTERNALLY_CONSUMED)
        });
    }

    pub(crate) fn apply_source_contracts(
        &mut self,
        contracts: source_closure::SourceClosureContracts,
    ) {
        if contracts
            .requesting_file_contract
            .externally_consumed_bindings
        {
            self.mark_file_entry_consumed_bindings();
        }
        if !contracts
            .requesting_file_contract
            .externally_consumed_binding_names
            .is_empty()
        {
            self.mark_file_entry_consumed_binding_names(
                &contracts
                    .requesting_file_contract
                    .externally_consumed_binding_names,
            );
        }
        if !contracts
            .requesting_file_contract
            .externally_consumed_binding_prefixes
            .is_empty()
        {
            self.mark_file_entry_consumed_binding_prefixes(
                &contracts
                    .requesting_file_contract
                    .externally_consumed_binding_prefixes,
            );
        }

        if contracts.synthetic_reads.is_empty()
            && contracts.imported_bindings.is_empty()
            && !contracts
                .requesting_file_contract
                .externally_consumed_bindings
            && contracts
                .requesting_file_contract
                .externally_consumed_binding_names
                .is_empty()
            && contracts
                .requesting_file_contract
                .externally_consumed_binding_prefixes
                .is_empty()
            && contracts.dependency_paths.is_empty()
            && contracts.source_ref_resolutions.is_empty()
            && contracts.source_ref_explicitness.is_empty()
            && contracts.source_ref_diagnostic_classes.is_empty()
        {
            return;
        }

        let mut merged_reads = self.synthetic_reads.clone();
        merged_reads.extend(contracts.synthetic_reads);
        self.set_synthetic_reads(dedup_synthetic_reads(merged_reads));
        if !contracts.dependency_paths.is_empty() {
            for path in contracts.dependency_paths {
                if !self.imported_dependency_paths.contains(&path) {
                    self.imported_dependency_paths.push(path);
                }
            }
            self.imported_dependency_paths.sort();
            self.imported_dependency_paths.dedup();
        }

        if !contracts.source_ref_resolutions.is_empty() {
            debug_assert_eq!(
                contracts.source_ref_resolutions.len(),
                self.source_refs.len()
            );
            for (source_ref, resolution) in self
                .source_refs
                .iter_mut()
                .zip(contracts.source_ref_resolutions)
            {
                source_ref.resolution = resolution;
            }
        }
        if !contracts.source_ref_explicitness.is_empty() {
            debug_assert_eq!(
                contracts.source_ref_explicitness.len(),
                self.source_refs.len()
            );
            for (source_ref, explicitly_provided) in self
                .source_refs
                .iter_mut()
                .zip(contracts.source_ref_explicitness)
            {
                source_ref.explicitly_provided = explicitly_provided;
            }
        }
        if !contracts.source_ref_diagnostic_classes.is_empty() {
            debug_assert_eq!(
                contracts.source_ref_diagnostic_classes.len(),
                self.source_refs.len()
            );
            for (source_ref, diagnostic_class) in self
                .source_refs
                .iter_mut()
                .zip(contracts.source_ref_diagnostic_classes)
            {
                source_ref.diagnostic_class = diagnostic_class;
            }
        }

        for site in contracts.imported_bindings {
            self.add_imported_binding(
                &site.binding,
                site.scope,
                site.span,
                Some(site.span),
                site.origin_paths,
                false,
            );
        }
        self.invalidate_function_binding_lookup();
        self.resolve_unresolved_references();
        self.invalidate_semantic_caches();
    }

    fn invalidate_function_binding_lookup(&mut self) {
        self.unconditional_function_bindings.take();
        self.function_bindings_by_scope.take();
        self.visible_function_call_bindings.take();
        self.function_definition_binding_ids.take();
    }

    fn invalidate_semantic_caches(&mut self) {
        self.call_graph.take();
        self.zsh_option_analysis.take();
        self.zsh_runtime_by_function.take();
        self.zsh_runtime_function_summaries.take();
    }

    fn resolve_unresolved_references(&mut self) {
        let unresolved = std::mem::take(&mut self.unresolved);
        for reference_id in unresolved {
            let reference = &self.references[reference_id.index()];
            let resolved =
                self.resolve_binding_at(&reference.name, reference.scope, reference.span);
            if let Some(binding_id) = resolved {
                self.resolved.insert(reference_id, binding_id);
                self.bindings[binding_id.index()]
                    .references
                    .push(reference_id);
            } else {
                self.unresolved.push(reference_id);
            }
        }
    }

    fn resolve_binding_at(&self, name: &Name, scope: ScopeId, span: Span) -> Option<BindingId> {
        for scope in self.ancestor_scopes(scope) {
            let Some(bindings) = self.scopes[scope.index()].bindings.get(name) else {
                continue;
            };

            for binding in bindings.iter().rev().copied() {
                if self.bindings[binding.index()].span.start.offset() <= span.start.offset() {
                    return Some(binding);
                }
            }
        }
        None
    }

    /// Returns same-name function-definition bindings for `name`, ordered by definition offset.
    pub fn function_definitions(&self, name: &Name) -> &[BindingId] {
        self.functions
            .get(name)
            .map(SmallVec::as_slice)
            .unwrap_or(&[])
    }

    /// Returns recorded call sites for `name`.
    pub fn call_sites_for(&self, name: &Name) -> &[CallSite] {
        self.call_sites
            .get(name)
            .map(SmallVec::as_slice)
            .unwrap_or(&[])
    }

    /// Iterates every recorded call site across all callee names.
    pub fn all_call_sites(&self) -> impl Iterator<Item = &CallSite> + '_ {
        self.call_sites.values().flat_map(|sites| sites.iter())
    }

    /// Returns the current call-graph summary for the model.
    pub fn call_graph(&self) -> &CallGraph {
        self.call_graph.get_or_init(|| {
            build_call_graph(
                &self.scopes,
                &self.bindings,
                &self.functions,
                &self.call_sites,
            )
        })
    }

    /// Returns declaration commands recorded in the file.
    pub fn declarations(&self) -> &[Declaration] {
        &self.declarations
    }

    /// Returns the declaration recorded for the command with syntax span `span`.
    pub fn declaration_for_command_span(&self, span: Span) -> Option<&Declaration> {
        let index = self
            .declarations_by_command_span
            .get_or_init(|| build_declarations_by_command_span(&self.declarations));
        index
            .get(&SpanKey::new(span))
            .map(|declaration_index| &self.declarations[*declaration_index])
    }

    /// Returns the function-definition binding recorded for command span `span`, if any.
    pub fn function_definition_binding_for_command_span(&self, span: Span) -> Option<BindingId> {
        self.command_bindings
            .get(&SpanKey::new(span))
            .and_then(|bindings| {
                bindings.iter().copied().find(|binding_id| {
                    matches!(
                        self.bindings[binding_id.index()].kind,
                        BindingKind::FunctionDefinition
                    )
                })
            })
    }

    /// Returns whether a function definition is installed on every path
    /// through its containing command sequence.
    pub fn function_binding_is_unconditional(&self, id: BindingId) -> bool {
        self.unconditional_function_bindings().contains(&id)
    }

    /// Returns source-like file references discovered in the script.
    pub fn source_refs(&self) -> &[SourceRef] {
        &self.source_refs
    }

    /// Returns whether a source operation is guaranteed to have executed in
    /// the completion point's enclosing execution scope.
    pub fn source_ref_visible_at_offset(&self, source_ref: &SourceRef, offset: usize) -> bool {
        if source_ref.conditionally_executed || source_ref.span.start.offset() >= offset {
            return false;
        }
        let source_scope = self.scope_at(source_ref.span.start.offset());
        let cursor_scope = self.scope_at(offset);
        if let Some(transient_scope) = self.innermost_transient_scope_within_function(source_scope)
        {
            return self.scope_is_in_scope_or_descendant(cursor_scope, transient_scope);
        }
        match (
            self.enclosing_function_scope(source_scope),
            self.enclosing_function_scope(cursor_scope),
        ) {
            (None, _) => true,
            (Some(source_function), Some(cursor_function)) => source_function == cursor_function,
            (Some(_), None) => false,
        }
    }

    /// Returns synthetic reads introduced by contracts or semantic modeling.
    pub fn synthetic_reads(&self) -> &[SyntheticRead] {
        &self.synthetic_reads
    }

    /// Returns helper and plugin files that influenced imported semantic contracts.
    pub fn imported_dependency_paths(&self) -> &[PathBuf] {
        &self.imported_dependency_paths
    }

    /// Returns flattened statement-sequence commands recorded during traversal.
    pub fn statement_sequence_commands(&self) -> &[StatementSequenceCommand] {
        self.recorded_program.statement_sequence_commands()
    }

    /// Returns the number of recorded semantic commands.
    pub fn command_count(&self) -> usize {
        self.recorded_program.commands().len()
    }

    pub(crate) fn recorded_program(&self) -> &RecordedProgram {
        &self.recorded_program
    }

    pub(crate) fn set_synthetic_reads(&mut self, synthetic_reads: Vec<SyntheticRead>) {
        self.synthetic_reads = synthetic_reads;
    }

    fn set_entry_bindings(&mut self, entry_bindings: Vec<BindingId>) {
        self.entry_bindings = entry_bindings;
    }

    fn function_binding_lookup(&self) -> FunctionBindingLookup<'_> {
        FunctionBindingLookup {
            program: &self.recorded_program,
            scopes: &self.scopes,
            bindings: &self.bindings,
            call_sites: &self.call_sites,
            unconditional_function_bindings: self.unconditional_function_bindings(),
            function_bindings_by_scope: self.function_binding_scope_index(),
        }
    }

    fn unconditional_function_bindings(&self) -> &FxHashSet<BindingId> {
        self.unconditional_function_bindings.get_or_init(|| {
            function_resolution::collect_unconditional_function_bindings(
                &self.recorded_program,
                &self.command_bindings,
                &self.bindings,
            )
        })
    }

    pub(crate) fn function_binding_scope_index(
        &self,
    ) -> &FxHashMap<ScopeId, SmallVec<[BindingId; 2]>> {
        self.function_bindings_by_scope
            .get_or_init(|| function_resolution::function_bindings_by_scope(&self.recorded_program))
    }

    pub(crate) fn visible_function_call_bindings(&self) -> &FxHashMap<SpanKey, BindingId> {
        self.visible_function_call_bindings.get_or_init(|| {
            self.function_binding_lookup()
                .visible_function_call_bindings()
        })
    }

    fn dataflow_context<'a>(&'a self, cfg: &'a ControlFlowGraph) -> DataflowContext<'a> {
        DataflowContext {
            cfg,
            runtime: &self.runtime,
            scopes: &self.scopes,
            bindings: &self.bindings,
            references: &self.references,
            predefined_runtime_refs: &self.predefined_runtime_refs,
            guarded_parameter_refs: &self.guarded_parameter_refs,
            parameter_guard_flow_refs: &self.parameter_guard_flow_refs,
            self_referential_assignment_refs: &self.self_referential_assignment_refs,
            resolved: &self.resolved,
            call_sites: &self.call_sites,
            visible_function_call_bindings: self.visible_function_call_bindings(),
            function_body_scopes: &self.recorded_program.function_body_scopes,
            indirect_targets_by_reference: &self.indirect_targets_by_reference,
            array_like_indirect_expansion_refs: &self.array_like_indirect_expansion_refs,
            synthetic_reads: &self.synthetic_reads,
            entry_bindings: &self.entry_bindings,
        }
    }
}

fn file_entry_contract_can_consume_binding(binding: &Binding) -> bool {
    if binding.attributes.contains(BindingAttributes::LOCAL) {
        return false;
    }

    file_entry_contract_can_consume_exact_binding(binding)
}

fn file_entry_contract_can_consume_exact_binding(binding: &Binding) -> bool {
    matches!(
        binding.kind,
        BindingKind::Assignment
            | BindingKind::ArrayAssignment
            | BindingKind::AppendAssignment
            | BindingKind::ParameterDefaultAssignment
            | BindingKind::LoopVariable
            | BindingKind::ReadTarget
            | BindingKind::MapfileTarget
            | BindingKind::PrintfTarget
            | BindingKind::GetoptsTarget
            | BindingKind::ArithmeticAssignment
            | BindingKind::Declaration(_)
    )
}

#[doc(hidden)]
pub fn build_with_observer_with_options<'a>(
    file: &'a File,
    source: &'a str,
    indexer: &Indexer,
    observer: &mut dyn TraversalObserver<'a>,
    options: SemanticBuildOptions<'_>,
) -> SemanticModel {
    build_semantic_model(file, source, indexer, observer, options)
}

#[doc(hidden)]
pub fn build_with_observer_at_path_with_resolver<'a>(
    file: &'a File,
    source: &'a str,
    indexer: &Indexer,
    observer: &mut dyn TraversalObserver<'a>,
    source_path: Option<&Path>,
    source_path_resolver: Option<&(dyn SourcePathResolver + Send + Sync)>,
) -> SemanticModel {
    build_semantic_model(
        file,
        source,
        indexer,
        observer,
        SemanticBuildOptions {
            source_path,
            source_path_resolver,
            plugin_resolver: None,
            file_entry_contract: None,
            file_entry_contract_collector: None,
            file_entry_contract_collector_factory: None,
            analyzed_paths: None,
            shell_profile: None,
            resolve_source_closure: true,
        },
    )
}

fn build_semantic_model<'a>(
    file: &'a File,
    source: &'a str,
    indexer: &Indexer,
    observer: &mut dyn TraversalObserver<'a>,
    options: SemanticBuildOptions<'_>,
) -> SemanticModel {
    let SemanticBuildOptions {
        source_path,
        source_path_resolver,
        plugin_resolver,
        file_entry_contract,
        mut file_entry_contract_collector,
        file_entry_contract_collector_factory,
        analyzed_paths,
        shell_profile,
        resolve_source_closure,
    } = options;
    let mut model = build_semantic_model_base(
        file,
        source,
        indexer,
        observer,
        source_path,
        shell_profile.clone(),
        file_entry_contract_collector
            .as_mut()
            .map(|collector| &mut **collector as &mut dyn FileEntryContractCollector),
    );
    if let Some(contract) = file_entry_contract {
        model.apply_file_entry_contract(contract, file);
    }
    if let Some(contract) = file_entry_contract_collector
        .as_ref()
        .and_then(|collector| collector.finish())
    {
        model.apply_file_entry_contract(contract, file);
    }
    if let Some(source_path) = source_path {
        let contracts = if resolve_source_closure {
            source_closure::collect_source_closure_contracts(
                &model,
                file,
                source,
                source_path,
                source_closure::SourceClosureResolverConfig {
                    source_path_resolver,
                    plugin_resolver,
                    file_entry_contract_collector_factory,
                    analyzed_paths,
                },
            )
        } else {
            let (source_ref_resolutions, source_ref_explicitness, source_ref_diagnostic_classes) =
                source_closure::collect_source_ref_metadata(
                    &model,
                    source_path,
                    source_path_resolver,
                    analyzed_paths,
                );
            source_closure::SourceClosureContracts::from_source_ref_metadata(
                source_ref_resolutions,
                source_ref_explicitness,
                source_ref_diagnostic_classes,
            )
        };
        model.apply_source_contracts(contracts);
    }
    model
}

pub(crate) fn build_semantic_model_base<'a, 'observer>(
    file: &'a File,
    source: &'a str,
    indexer: &Indexer,
    observer: &'observer mut dyn TraversalObserver<'a>,
    source_path: Option<&Path>,
    shell_profile: Option<ShellProfile>,
    file_entry_contract_collector: Option<&'observer mut dyn FileEntryContractCollector>,
) -> SemanticModel {
    let shell_profile = shell_profile.unwrap_or_else(|| infer_shell_profile(source, source_path));
    let built = SemanticModelBuilder::build(
        file,
        source,
        indexer,
        observer,
        file_entry_contract_collector,
        bash_runtime_vars_enabled(source, source_path),
        shell_profile,
    );
    SemanticModel::from_build_output(built)
}

fn infer_shell_profile(source: &str, path: Option<&Path>) -> ShellProfile {
    let dialect = infer_parse_dialect_from_source(source, path);
    ShellProfile::native(dialect)
}

fn infer_parse_dialect_from_source(
    source: &str,
    path: Option<&Path>,
) -> shuck_parser::ShellDialect {
    if let Some(interpreter) = shebang_interpreter(source) {
        return parse_dialect_from_name(interpreter).unwrap_or(shuck_parser::ShellDialect::Bash);
    }

    infer_parse_dialect_from_path(path).unwrap_or(shuck_parser::ShellDialect::Bash)
}

pub(crate) fn infer_explicit_parse_dialect_from_source(
    source: &str,
    path: Option<&Path>,
) -> Option<shuck_parser::ShellDialect> {
    if let Some(interpreter) = shebang_interpreter(source)
        && let Some(dialect) = parse_dialect_from_name(interpreter)
    {
        return Some(dialect);
    }

    infer_parse_dialect_from_path(path)
}

fn shebang_interpreter(source: &str) -> Option<&str> {
    let first_line_end = source.find('\n').unwrap_or(source.len());
    let first_line = source.get(..first_line_end)?;
    shuck_parser::shebang::interpreter_name(first_line.strip_suffix('\r').unwrap_or(first_line))
}

fn infer_parse_dialect_from_path(path: Option<&Path>) -> Option<shuck_parser::ShellDialect> {
    match path
        .and_then(|path| path.extension().and_then(|ext| ext.to_str()))
        .map(|ext| ext.to_ascii_lowercase())
        .as_deref()
    {
        Some("sh" | "dash" | "ksh") => Some(shuck_parser::ShellDialect::Posix),
        Some("mksh") => Some(shuck_parser::ShellDialect::Mksh),
        Some("bash") => Some(shuck_parser::ShellDialect::Bash),
        Some("zsh") => Some(shuck_parser::ShellDialect::Zsh),
        _ => None,
    }
}

fn parse_dialect_from_name(name: &str) -> Option<shuck_parser::ShellDialect> {
    match name.to_ascii_lowercase().as_str() {
        "sh" | "dash" | "ksh" | "posix" => Some(shuck_parser::ShellDialect::Posix),
        "mksh" => Some(shuck_parser::ShellDialect::Mksh),
        "bash" => Some(shuck_parser::ShellDialect::Bash),
        "zsh" => Some(shuck_parser::ShellDialect::Zsh),
        _ => None,
    }
}

fn bash_runtime_vars_enabled(source: &str, path: Option<&Path>) -> bool {
    infer_bash_from_shebang(source).unwrap_or_else(|| {
        path.and_then(|path| path.extension().and_then(|ext| ext.to_str()))
            .is_some_and(|ext| ext.eq_ignore_ascii_case("bash"))
    })
}

fn infer_bash_from_shebang(source: &str) -> Option<bool> {
    shebang_interpreter(source).map(|interpreter| interpreter.eq_ignore_ascii_case("bash"))
}

fn contains_offset(span: Span, offset: usize) -> bool {
    span.start.offset() <= offset && offset <= span.end.offset()
}

/// Flattens the scope tree into (start offset, innermost scope) segments.
///
/// The innermost scope only changes at a scope-span start or one past a
/// scope-span end, so evaluating the tree descent at each such breakpoint
/// yields a table that answers `scope_at` with one binary search while
/// reproducing the descent's tie-breaking exactly.
fn build_scope_at_segments(scopes: &[Scope], lookup: &ScopeLookup) -> Vec<(usize, ScopeId)> {
    let mut breakpoints = Vec::with_capacity(scopes.len() * 2 + 1);
    breakpoints.push(0usize);
    for scope in scopes {
        breakpoints.push(scope.span.start.offset());
        breakpoints.push(scope.span.end.offset() + 1);
    }
    breakpoints.sort_unstable();
    breakpoints.dedup();

    let mut segments = Vec::with_capacity(breakpoints.len());
    for breakpoint in breakpoints {
        let scope = lookup.scope_at(scopes, breakpoint).unwrap_or(ScopeId(0));
        if segments.last().map(|(_, last)| *last) != Some(scope) {
            segments.push((breakpoint, scope));
        }
    }
    segments
}

fn build_references_sorted_by_start(references: &[Reference]) -> Vec<ReferenceId> {
    let mut ids: Vec<ReferenceId> = (0..references.len() as u32).map(ReferenceId).collect();
    ids.sort_by_key(|id| references[id.index()].span.start.offset());
    ids
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PositionalParameterReferenceKind {
    Indexed(usize),
    Special,
}

fn positional_parameter_reference_kind(name: &str) -> Option<PositionalParameterReferenceKind> {
    match name {
        "@" | "*" | "#" => Some(PositionalParameterReferenceKind::Special),
        "0" => None,
        _ if name.chars().all(|ch| ch.is_ascii_digit()) => name
            .parse::<usize>()
            .ok()
            .map(PositionalParameterReferenceKind::Indexed),
        _ => None,
    }
}

fn reference_has_local_positional_reset(
    semantic: &SemanticModel,
    scope: ScopeId,
    offset: usize,
    local_reset_offsets_by_scope: &FxHashMap<ScopeId, Vec<usize>>,
) -> bool {
    semantic
        .transient_ancestor_scopes_within_function(scope)
        .any(|transient_scope| {
            local_reset_offsets_by_scope
                .get(&transient_scope)
                .is_some_and(|offsets| offsets.iter().any(|reset_offset| *reset_offset < offset))
        })
}

fn build_bindings_sorted_by_start(bindings: &[Binding]) -> Vec<BindingId> {
    let mut ids: Vec<BindingId> = (0..bindings.len() as u32).map(BindingId).collect();
    ids.sort_by_key(|id| bindings[id.index()].span.start.offset());
    ids
}

fn build_guarded_or_defaulting_reference_offsets_by_name(
    references: &[Reference],
    guarded_parameter_refs: &FxHashSet<ReferenceId>,
    defaulting_parameter_operand_refs: &FxHashSet<ReferenceId>,
) -> FxHashMap<Name, Box<[usize]>> {
    let mut offsets_by_name = FxHashMap::<Name, Vec<usize>>::default();

    for reference in references {
        if guarded_parameter_refs.contains(&reference.id)
            || defaulting_parameter_operand_refs.contains(&reference.id)
        {
            offsets_by_name
                .entry(reference.name.clone())
                .or_default()
                .push(reference.span.start.offset());
        }
    }

    offsets_by_name
        .into_iter()
        .map(|(name, mut offsets)| {
            offsets.sort_unstable();
            offsets.dedup();
            (name, offsets.into_boxed_slice())
        })
        .collect()
}

fn build_declarations_by_command_span(declarations: &[Declaration]) -> FxHashMap<SpanKey, usize> {
    let mut index = FxHashMap::with_capacity_and_hasher(declarations.len(), Default::default());
    for (declaration_index, declaration) in declarations.iter().enumerate() {
        index.insert(SpanKey::new(declaration.span), declaration_index);
    }
    index
}

fn build_bindings_by_definition_span(bindings: &[Binding]) -> FxHashMap<SpanKey, BindingId> {
    let mut index = FxHashMap::with_capacity_and_hasher(bindings.len(), Default::default());
    for binding in bindings {
        index.insert(SpanKey::new(binding.span), binding.id);
    }
    index
}

/// Iterator returned by [`SemanticModel::references_in_span`].
///
/// Walks the references sorted index forward from the first candidate and
/// stops as soon as a reference starts past the outer span's end.
#[derive(Debug, Clone)]
pub struct ReferencesInSpan<'a> {
    references: &'a [Reference],
    ids: std::slice::Iter<'a, ReferenceId>,
    end: usize,
}

impl<'a> Iterator for ReferencesInSpan<'a> {
    type Item = &'a Reference;

    fn next(&mut self) -> Option<&'a Reference> {
        loop {
            let id = self.ids.next()?;
            let reference = &self.references[id.index()];
            if reference.span.start.offset() > self.end {
                return None;
            }
            if reference.span.end.offset() <= self.end {
                return Some(reference);
            }
        }
    }
}

/// Iterator returned by [`SemanticModel::references_in_command_span`].
///
/// Walks the direct reference ids recorded for a single command and yields
/// only those fully contained within the requested subspan.
#[derive(Debug, Clone)]
pub struct CommandReferencesInSpan<'a> {
    references: &'a [Reference],
    ids: std::slice::Iter<'a, ReferenceId>,
    outer: Span,
}

impl<'a> Iterator for CommandReferencesInSpan<'a> {
    type Item = &'a Reference;

    fn next(&mut self) -> Option<&'a Reference> {
        loop {
            let id = self.ids.next()?;
            let reference = &self.references[id.index()];
            if contains_span(self.outer, reference.span) {
                return Some(reference);
            }
        }
    }
}

/// Iterator returned by [`SemanticModel::bindings_in_span`].
///
/// Walks the bindings sorted index forward from the first candidate and
/// stops as soon as a binding starts past the outer span's end.
#[derive(Debug, Clone)]
pub struct BindingsInSpan<'a> {
    bindings: &'a [Binding],
    ids: std::slice::Iter<'a, BindingId>,
    end: usize,
}

impl<'a> Iterator for BindingsInSpan<'a> {
    type Item = &'a Binding;

    fn next(&mut self) -> Option<&'a Binding> {
        loop {
            let id = self.ids.next()?;
            let binding = &self.bindings[id.index()];
            if binding.span.start.offset() > self.end {
                return None;
            }
            if binding.span.end.offset() <= self.end {
                return Some(binding);
            }
        }
    }
}

fn scope_span_width(span: Span) -> usize {
    span.end.offset().saturating_sub(span.start.offset())
}

fn contains_span(outer: Span, inner: Span) -> bool {
    outer.start.offset() <= inner.start.offset() && outer.end.offset() >= inner.end.offset()
}

#[cfg(test)]
fn linear_scope_at(scopes: &[Scope], offset: usize) -> ScopeId {
    scopes
        .iter()
        .filter(|scope| contains_offset(scope.span, offset))
        .min_by_key(|scope| scope_span_width(scope.span))
        .map(|scope| scope.id)
        .unwrap_or(ScopeId(0))
}

fn build_indirect_targets_by_binding(
    bindings: &[Binding],
    indirect_target_hints: &FxHashMap<BindingId, IndirectTargetHint>,
) -> FxHashMap<BindingId, Vec<BindingId>> {
    let mut targets_by_binding = FxHashMap::default();
    if indirect_target_hints.is_empty() {
        return targets_by_binding;
    }

    let needs_name_map = indirect_target_hints
        .values()
        .any(|hint| matches!(hint, IndirectTargetHint::Exact { .. }));
    let bindings_by_name = needs_name_map.then(|| {
        let mut by_name: FxHashMap<&Name, smallvec::SmallVec<[&Binding; 2]>> = FxHashMap::default();
        for binding in bindings {
            by_name.entry(&binding.name).or_default().push(binding);
        }
        by_name
    });

    for (binding_id, hint) in indirect_target_hints {
        let targets: Vec<_> = match hint {
            IndirectTargetHint::Exact { name, array_like } => bindings_by_name
                .as_ref()
                .and_then(|by_name| by_name.get(name))
                .map(|candidates| {
                    candidates
                        .iter()
                        .filter(|binding| !array_like || binding::is_array_like_binding(binding))
                        .map(|binding| binding.id)
                        .collect()
                })
                .unwrap_or_default(),
            IndirectTargetHint::Pattern { .. } => bindings
                .iter()
                .filter(|binding| indirect_target_matches(hint, binding))
                .map(|binding| binding.id)
                .collect(),
        };
        if !targets.is_empty() {
            targets_by_binding.insert(*binding_id, targets);
        }
    }
    targets_by_binding
}

fn build_indirect_targets_by_reference(
    references: &[Reference],
    resolved: &FxHashMap<ReferenceId, BindingId>,
    indirect_expansion_refs: &FxHashSet<ReferenceId>,
    indirect_targets_by_binding: &FxHashMap<BindingId, Vec<BindingId>>,
) -> FxHashMap<ReferenceId, Vec<BindingId>> {
    let mut targets_by_reference = FxHashMap::default();
    for reference in references {
        if !indirect_expansion_refs.contains(&reference.id) {
            continue;
        }
        let Some(binding_id) = resolved.get(&reference.id).copied() else {
            continue;
        };
        if let Some(targets) = indirect_targets_by_binding.get(&binding_id) {
            targets_by_reference.insert(reference.id, targets.clone());
        }
    }
    targets_by_reference
}

fn build_array_like_indirect_expansion_refs(
    references: &[Reference],
    resolved: &FxHashMap<ReferenceId, BindingId>,
    indirect_expansion_refs: &FxHashSet<ReferenceId>,
    indirect_target_hints: &FxHashMap<BindingId, IndirectTargetHint>,
) -> FxHashSet<ReferenceId> {
    let mut array_like_refs = FxHashSet::default();
    for reference in references {
        if !indirect_expansion_refs.contains(&reference.id) {
            continue;
        }
        let Some(binding_id) = resolved.get(&reference.id).copied() else {
            continue;
        };
        let Some(hint) = indirect_target_hints.get(&binding_id) else {
            continue;
        };
        let array_like = match hint {
            IndirectTargetHint::Exact { array_like, .. }
            | IndirectTargetHint::Pattern { array_like, .. } => *array_like,
        };
        if array_like {
            array_like_refs.insert(reference.id);
        }
    }
    array_like_refs
}

fn indirect_target_matches(hint: &IndirectTargetHint, binding: &Binding) -> bool {
    match hint {
        IndirectTargetHint::Exact { name, array_like } => {
            binding.name == *name && (!array_like || binding::is_array_like_binding(binding))
        }
        IndirectTargetHint::Pattern {
            prefix,
            suffix,
            array_like,
        } => {
            let name = binding.name.as_str();
            name.starts_with(prefix.as_str())
                && name.ends_with(suffix.as_str())
                && (!array_like || binding::is_array_like_binding(binding))
        }
    }
}

#[cfg(test)]
mod plugins;
#[cfg(test)]
mod tests;
