//! Source-closure analysis.
//!
//! This module owns the core machinery for turning `source` calls and configured
//! plugin entrypoints into semantic contracts that can be applied to the caller.
//! It keeps the public `source_closure` surface stable while delegating
//! zsh-specific plugin request discovery and deferred callback modeling to
//! focused plugin-manager implementations.

use std::cell::RefCell;
use std::env;
use std::fs;
use std::path::{Component, Path, PathBuf};

use rustc_hash::{FxHashMap, FxHashSet};
use shuck_ast::{
    ArithmeticExpr, ArithmeticExprNode, ArrayElem, Assignment, AssignmentValue,
    BourneParameterExpansion, Command, DeclOperand, File, Name, ParameterExpansion,
    ParameterExpansionSyntax, SimpleCommand, Span, StmtSeq, VarRef, Word, WordPart, WordPartNode,
    ZshDefaultingOp, ZshExpansionOperation, ZshExpansionTarget, ZshParameterExpansion,
    static_word_text,
};
use shuck_indexer::Indexer;
use shuck_parser::parser::Parser;
use shuck_parser::{ShellDialect as ParseShellDialect, ShellProfile, ZshOptionState};

use crate::function_resolution::{
    call_payloads_by_callee_scope, lexically_visible_function_binding_in_scope,
};
mod plugin_managers;

pub use plugin_managers::{layout_for_plugin_framework, zsh_plugin_frameworks};

use plugin_managers::{
    collect_plugin_requests, deferred_zsh_entrypoint_required_reads, sorted_dependency_paths,
};

use crate::{
    Binding, BindingAttributes, BindingKind, ContractCertainty, FileContract,
    FileEntryContractCollector, FileEntryContractCollectorFactory, FunctionContract,
    FunctionScopeKind, PluginFramework, PluginRequest, PluginRequestKind, PluginResolver,
    ProvidedBinding, ProvidedBindingKind, ScopeId, ScopeKind, SemanticModel, SourcePathResolver,
    SourceRefDiagnosticClass, SourceRefKind, SourceRefResolution, SpanKey, SyntheticRead,
    build_semantic_model_base, dataflow::binding_initializes_name,
    dataflow::function_binding_certainty, infer_explicit_parse_dialect_from_source,
};

#[derive(Debug, Clone, Default)]
pub(crate) struct SourceClosureContracts {
    pub(crate) synthetic_reads: Vec<SyntheticRead>,
    pub(crate) imported_bindings: Vec<ImportedBindingContractSite>,
    imported_functions: Vec<ImportedFunctionContractSite>,
    pub(crate) requesting_file_contract: FileContract,
    pub(crate) dependency_paths: Vec<PathBuf>,
    pub(crate) source_ref_resolutions: Vec<SourceRefResolution>,
    pub(crate) source_ref_explicitness: Vec<bool>,
    pub(crate) source_ref_diagnostic_classes: Vec<SourceRefDiagnosticClass>,
}

impl SourceClosureContracts {
    pub(crate) fn from_source_ref_metadata(
        source_ref_resolutions: Vec<SourceRefResolution>,
        source_ref_explicitness: Vec<bool>,
        source_ref_diagnostic_classes: Vec<SourceRefDiagnosticClass>,
    ) -> Self {
        Self {
            synthetic_reads: Vec::new(),
            imported_bindings: Vec::new(),
            imported_functions: Vec::new(),
            requesting_file_contract: FileContract::default(),
            dependency_paths: Vec::new(),
            source_ref_resolutions,
            source_ref_explicitness,
            source_ref_diagnostic_classes,
        }
    }
}

type SourceRefMetadataResult = (
    Vec<SourceRefResolution>,
    Vec<bool>,
    Vec<SourceRefDiagnosticClass>,
);

#[derive(Clone)]
struct SourceClosureLookupContext<'a> {
    source_path_resolver: Option<&'a (dyn SourcePathResolver + Send + Sync)>,
    plugin_resolver: Option<&'a (dyn PluginResolver + Send + Sync)>,
    file_entry_contract_collector_factory:
        Option<&'a (dyn FileEntryContractCollectorFactory + Send + Sync)>,
    analyzed_paths: Option<&'a FxHashSet<PathBuf>>,
    shell_profile: ShellProfile,
    resolved_helper_paths: RefCell<FxHashMap<HelperPathResolutionKey, HelperPathResolution>>,
    dependency_paths: RefCell<FxHashSet<PathBuf>>,
}

pub(crate) struct SourceClosureResolverConfig<'a> {
    pub(crate) source_path_resolver: Option<&'a (dyn SourcePathResolver + Send + Sync)>,
    pub(crate) plugin_resolver: Option<&'a (dyn PluginResolver + Send + Sync)>,
    pub(crate) file_entry_contract_collector_factory:
        Option<&'a (dyn FileEntryContractCollectorFactory + Send + Sync)>,
    pub(crate) analyzed_paths: Option<&'a FxHashSet<PathBuf>>,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct HelperPathResolutionKey {
    source_path: PathBuf,
    candidate: compact_str::CompactString,
}

#[derive(Debug, Clone, Default)]
struct HelperPathResolution {
    paths: Vec<PathBuf>,
    plugin_resolved: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct HelperSummaryKey {
    path: PathBuf,
    shell_profile: ShellProfileKey,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ShellProfileKey {
    dialect: ParseShellDialect,
    options: Option<ZshOptionState>,
}

impl ShellProfileKey {
    fn from_profile(profile: &ShellProfile) -> Self {
        Self {
            dialect: profile.dialect,
            options: profile.options,
        }
    }
}

#[derive(Debug, Clone)]
pub(crate) struct ImportedBindingContractSite {
    pub(crate) scope: ScopeId,
    pub(crate) span: Span,
    pub(crate) binding: ProvidedBinding,
    pub(crate) origin_paths: Vec<PathBuf>,
}

#[derive(Debug, Clone)]
struct ImportedFunctionContractSite {
    scope: ScopeId,
    span: Span,
    certainty: ContractCertainty,
    trust_provided_bindings: bool,
    contract: FunctionContract,
}

pub(crate) fn collect_source_closure_contracts(
    model: &SemanticModel,
    file: &File,
    source: &str,
    source_path: &Path,
    config: SourceClosureResolverConfig<'_>,
) -> SourceClosureContracts {
    let mut summaries = FxHashMap::default();
    let mut active = FxHashSet::default();
    let context = SourceClosureLookupContext {
        source_path_resolver: config.source_path_resolver,
        plugin_resolver: config.plugin_resolver,
        file_entry_contract_collector_factory: config.file_entry_contract_collector_factory,
        analyzed_paths: config.analyzed_paths,
        shell_profile: model.shell_profile().clone(),
        resolved_helper_paths: RefCell::new(FxHashMap::default()),
        dependency_paths: RefCell::new(FxHashSet::default()),
    };
    collect_source_closure_contracts_with_cache(
        model,
        file,
        source,
        source_path,
        &mut summaries,
        &mut active,
        &context,
    )
}

pub(crate) fn collect_source_ref_metadata(
    model: &SemanticModel,
    source_path: &Path,
    source_path_resolver: Option<&(dyn SourcePathResolver + Send + Sync)>,
    analyzed_paths: Option<&FxHashSet<PathBuf>>,
) -> SourceRefMetadataResult {
    let facts = collect_ast_facts(model);
    let call_args_by_scope = if facts.source_templates_use_positional_args {
        resolve_literal_call_args_by_scope(model, &facts.calls)
    } else {
        FxHashMap::default()
    };
    let context = SourceClosureLookupContext {
        source_path_resolver,
        plugin_resolver: None,
        file_entry_contract_collector_factory: None,
        analyzed_paths,
        shell_profile: model.shell_profile().clone(),
        resolved_helper_paths: RefCell::new(FxHashMap::default()),
        dependency_paths: RefCell::new(FxHashSet::default()),
    };
    let mut source_ref_resolutions = Vec::new();
    let mut source_ref_explicitness = Vec::new();
    let mut source_ref_diagnostic_classes = Vec::new();

    for source_ref in model.source_refs() {
        let scope = model.scope_at(source_ref.span.start.offset());
        let template = effective_source_template(
            model,
            source_ref,
            facts.source_templates.get(&SpanKey::new(source_ref.span)),
        );
        let candidates = source_candidates(
            &source_ref.kind,
            template.as_ref(),
            call_args_by_scope.get(&scope).map(Vec::as_slice),
            source_path,
        );
        let (resolved, mut explicit) =
            source_ref_metadata_for_candidates(source_path, candidates, &context);
        let has_current_source_anchor = template
            .as_ref()
            .is_some_and(template_has_current_source_anchor);
        if has_current_source_anchor
            && (resolved || model.shell_profile().dialect == ParseShellDialect::Zsh)
        {
            explicit = true;
        }
        // See the closure path: a resolved shuck-native directive is an
        // explicit assertion.
        if resolved && source_ref.has_shuck_directive() {
            explicit = true;
        }

        source_ref_resolutions.push(classify_source_ref_resolution(&source_ref.kind, resolved));
        source_ref_explicitness.push(explicit);
        source_ref_diagnostic_classes.push(classify_source_ref_diagnostic_class(
            source_ref,
            template.as_ref(),
        ));
    }

    (
        source_ref_resolutions,
        source_ref_explicitness,
        source_ref_diagnostic_classes,
    )
}

fn collect_source_closure_contracts_with_cache(
    model: &SemanticModel,
    file: &File,
    source: &str,
    source_path: &Path,
    summaries: &mut FxHashMap<HelperSummaryKey, FileContract>,
    active: &mut FxHashSet<HelperSummaryKey>,
    context: &SourceClosureLookupContext<'_>,
) -> SourceClosureContracts {
    let facts = collect_ast_facts(model);
    let function_binding_lookup = model.function_binding_lookup();
    let call_args_by_scope = if facts.source_templates_use_positional_args {
        resolve_literal_call_args_by_scope(model, &facts.calls)
    } else {
        FxHashMap::default()
    };
    let mut synthetic_reads = Vec::new();
    let mut imported_bindings = Vec::new();
    let mut imported_functions = Vec::new();
    let mut requesting_file_contract = FileContract::default();
    let mut source_ref_resolutions = Vec::new();
    let mut source_ref_explicitness = Vec::new();
    let mut source_ref_diagnostic_classes = Vec::new();

    for source_ref in model.source_refs() {
        let scope = model.scope_at(source_ref.span.start.offset());
        let template = effective_source_template(
            model,
            source_ref,
            facts.source_templates.get(&SpanKey::new(source_ref.span)),
        );
        let candidates = source_candidates(
            &source_ref.kind,
            template.as_ref(),
            call_args_by_scope.get(&scope).map(Vec::as_slice),
            source_path,
        );

        let (contract, resolved, mut explicit) =
            merge_contracts_for_candidates(source_path, candidates, summaries, active, context);
        let has_current_source_anchor = template
            .as_ref()
            .is_some_and(template_has_current_source_anchor);
        if has_current_source_anchor
            && (resolved || model.shell_profile().dialect == ParseShellDialect::Zsh)
        {
            explicit = true;
        }
        // A shuck-native `# shuck: source=` directive that resolves is an
        // explicit user assertion of the target, so treat it as explicitly
        // provided (silencing the untracked-source diagnostic) even when the
        // target is not part of the analyzed set. `# shellcheck source=` keeps
        // ShellCheck's not-specified-as-input semantics and is not silenced here.
        if resolved && source_ref.has_shuck_directive() {
            explicit = true;
        }
        let trust_provided_bindings =
            source_ref_can_import_provided_bindings(&source_ref.kind, template.as_ref());
        source_ref_resolutions.push(classify_source_ref_resolution(&source_ref.kind, resolved));
        source_ref_explicitness.push(explicit);
        source_ref_diagnostic_classes.push(classify_source_ref_diagnostic_class(
            source_ref,
            template.as_ref(),
        ));
        if trust_provided_bindings {
            for provided in contract.provided_bindings.iter().cloned() {
                imported_bindings.push(ImportedBindingContractSite {
                    scope,
                    span: source_ref.span,
                    origin_paths: binding_origin_paths(&contract, &provided),
                    binding: provided,
                });
            }
        }
        imported_functions.extend(imported_function_sites_for_contract(
            scope,
            source_ref.span,
            &contract,
            trust_provided_bindings,
        ));
        for name in contract.required_reads {
            synthetic_reads.push(SyntheticRead {
                scope,
                span: source_ref.span,
                name,
            });
        }
    }

    if let Some(plugin_resolver) = context.plugin_resolver {
        for request in collect_plugin_requests(model, file, source, source_path, plugin_resolver) {
            let scope = model.scope_at(request.span.start.offset());
            let resolution = plugin_resolver.resolve_plugin_request(source_path, &request);
            requesting_file_contract = FileContract::merge_candidate_contracts(&[
                requesting_file_contract,
                resolution.requesting_file_contract.clone(),
            ]);
            let mut contracts = resolution.file_entry_contracts;
            for entrypoint in resolution.entrypoints {
                contracts.push(summarize_helper(&entrypoint, summaries, active, context));
            }
            let contract = FileContract::merge_candidate_contracts(&contracts);
            for provided in contract.provided_bindings.iter().cloned() {
                imported_bindings.push(ImportedBindingContractSite {
                    scope,
                    span: request.span,
                    origin_paths: binding_origin_paths(&contract, &provided),
                    binding: provided,
                });
            }
            imported_functions.extend(imported_function_sites_for_contract(
                scope,
                request.span,
                &contract,
                true,
            ));
            for name in contract.required_reads {
                synthetic_reads.push(SyntheticRead {
                    scope,
                    span: request.span,
                    name,
                });
            }
        }
    }

    for call in &facts.calls {
        let imported_function_site = visible_imported_function_contract(
            model,
            &imported_functions,
            &call.name,
            call.scope,
            call.span.start.offset(),
        );
        if let Some(function_site) = imported_function_site {
            for name in &function_site.contract.required_reads {
                synthetic_reads.push(SyntheticRead {
                    scope: call.scope,
                    span: call.span,
                    name: name.clone(),
                });
            }
            if function_site.trust_provided_bindings {
                for binding in &function_site.contract.provided_bindings {
                    imported_bindings.push(ImportedBindingContractSite {
                        scope: call.scope,
                        span: call.span,
                        binding: binding_for_imported_function_call(
                            binding,
                            function_site.certainty,
                        ),
                        origin_paths: Vec::new(),
                    });
                }
            }
        }
        if imported_function_site.is_none()
            && function_binding_lookup
                .visible_function_binding(&call.name, call.scope, call.span.start.offset())
                .is_none()
            && let Some(bindings) = unresolved_zsh_reply_bindings_for_call(
                source_path,
                &context.shell_profile,
                &call.name,
            )
        {
            for binding in bindings {
                imported_bindings.push(ImportedBindingContractSite {
                    scope: call.scope,
                    span: call.span,
                    binding,
                    origin_paths: Vec::new(),
                });
            }
        }

        let Some(candidate) = local_helper_command_candidate(&call.name) else {
            continue;
        };
        let (contract, _, _) =
            merge_contracts_for_candidates(source_path, [candidate], summaries, active, context);
        for name in contract.required_reads {
            synthetic_reads.push(SyntheticRead {
                scope: call.scope,
                span: call.span,
                name,
            });
        }
    }

    SourceClosureContracts {
        synthetic_reads: dedup_synthetic_reads(synthetic_reads),
        imported_bindings: dedup_imported_bindings(imported_bindings),
        imported_functions,
        requesting_file_contract,
        dependency_paths: sorted_dependency_paths(&context.dependency_paths.borrow()),
        source_ref_resolutions,
        source_ref_explicitness,
        source_ref_diagnostic_classes,
    }
}

fn merge_contracts_for_candidates(
    source_path: &Path,
    candidates: impl IntoIterator<Item = String>,
    summaries: &mut FxHashMap<HelperSummaryKey, FileContract>,
    active: &mut FxHashSet<HelperSummaryKey>,
    context: &SourceClosureLookupContext<'_>,
) -> (FileContract, bool, bool) {
    let mut contracts = Vec::new();
    let mut resolved = false;
    let mut explicit = false;
    for candidate in candidates {
        let resolution = resolve_helper_paths_cached(source_path, &candidate, context);
        resolved |= !resolution.paths.is_empty();
        explicit |= resolution.plugin_resolved
            || resolution
                .paths
                .iter()
                .any(|path| path_is_explicitly_analyzed(path, context.analyzed_paths));
        for resolved_path in resolution.paths {
            contracts.push(summarize_helper(&resolved_path, summaries, active, context));
        }
    }
    (
        FileContract::merge_candidate_contracts(&contracts),
        resolved,
        explicit,
    )
}

fn source_ref_metadata_for_candidates(
    source_path: &Path,
    candidates: impl IntoIterator<Item = String>,
    context: &SourceClosureLookupContext<'_>,
) -> (bool, bool) {
    let mut resolved = false;
    let mut explicit = false;
    for candidate in candidates {
        let resolution = resolve_helper_paths_cached(source_path, &candidate, context);
        resolved |= !resolution.paths.is_empty();
        explicit |= resolution.plugin_resolved
            || resolution
                .paths
                .iter()
                .any(|path| path_is_explicitly_analyzed(path, context.analyzed_paths));
    }

    (resolved, explicit)
}

fn path_is_explicitly_analyzed(path: &Path, analyzed_paths: Option<&FxHashSet<PathBuf>>) -> bool {
    analyzed_paths.is_some_and(|paths| {
        if paths.contains(path) {
            return true;
        }

        let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
        paths.contains(&canonical)
            || paths.iter().any(|analyzed| {
                fs::canonicalize(analyzed)
                    .map(|analyzed| analyzed == canonical)
                    .unwrap_or(false)
            })
    })
}

fn classify_source_ref_resolution(kind: &SourceRefKind, resolved: bool) -> SourceRefResolution {
    match kind {
        SourceRefKind::DirectiveDevNull => SourceRefResolution::Resolved,
        SourceRefKind::Literal(_)
        | SourceRefKind::Directive(_)
        | SourceRefKind::Dynamic
        | SourceRefKind::SingleVariableStaticTail { .. } => {
            if resolved {
                SourceRefResolution::Resolved
            } else {
                SourceRefResolution::Unresolved
            }
        }
    }
}

fn effective_source_template(
    model: &SemanticModel,
    source_ref: &crate::SourceRef,
    syntax_template: Option<&SourcePathTemplate>,
) -> Option<SourcePathTemplate> {
    if let SourceRefKind::SingleVariableStaticTail { variable, tail } = &source_ref.kind
        && let Some(binding) = model.visible_binding(variable, source_ref.path_span)
        && binding_is_plain_assignment(binding)
        && let Some(template) = model.source_path_templates_by_binding.get(&binding.id)
    {
        return Some(template_with_literal_tail(template, tail));
    }

    syntax_template.cloned()
}

fn binding_is_plain_assignment(binding: &Binding) -> bool {
    matches!(binding.kind, BindingKind::Assignment)
}

fn source_ref_can_import_provided_bindings(
    kind: &SourceRefKind,
    template: Option<&SourcePathTemplate>,
) -> bool {
    match kind {
        SourceRefKind::Literal(_) | SourceRefKind::Directive(_) => true,
        SourceRefKind::DirectiveDevNull => false,
        SourceRefKind::Dynamic | SourceRefKind::SingleVariableStaticTail { .. } => {
            template.is_some_and(template_has_current_source_anchor)
        }
    }
}

fn template_has_current_source_anchor(template: &SourcePathTemplate) -> bool {
    match template {
        SourcePathTemplate::Interpolated(parts) => parts
            .iter()
            .any(|part| matches!(part, TemplatePart::SourceDir | TemplatePart::SourceFile)),
    }
}

fn classify_source_ref_diagnostic_class(
    source_ref: &crate::SourceRef,
    template: Option<&SourcePathTemplate>,
) -> SourceRefDiagnosticClass {
    match source_ref.kind {
        SourceRefKind::Dynamic if template_is_untracked_file(template) => {
            SourceRefDiagnosticClass::UntrackedFile
        }
        _ => source_ref.diagnostic_class,
    }
}

fn template_is_untracked_file(template: Option<&SourcePathTemplate>) -> bool {
    let Some(SourcePathTemplate::Interpolated(parts)) = template else {
        return false;
    };

    matches!(
        parts.as_slice(),
        [TemplatePart::Literal(path)] if path.contains('/')
    ) || matches!(
        parts.as_slice(),
        [TemplatePart::SourceDir, TemplatePart::Literal(tail)] if tail.starts_with('/')
    )
}

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 dedup_imported_bindings(
    bindings: Vec<ImportedBindingContractSite>,
) -> Vec<ImportedBindingContractSite> {
    let mut merged = FxHashMap::default();
    for site in bindings {
        let ImportedBindingContractSite {
            scope,
            span,
            binding,
            origin_paths,
        } = site;
        let key = (
            scope,
            span.start.offset(),
            binding.name.clone(),
            binding.kind,
        );
        let entry = merged
            .entry(key)
            .or_insert((span, binding.certainty, Vec::<PathBuf>::new()));
        entry.1 = entry.1.merge_same_site(binding.certainty);
        merge_origin_paths(&mut entry.2, &origin_paths);
    }

    let mut deduped = Vec::new();
    for ((scope, _, name, kind), (span, certainty, origin_paths)) in merged {
        deduped.push(ImportedBindingContractSite {
            scope,
            span,
            binding: ProvidedBinding::new(name, kind, certainty),
            origin_paths,
        });
    }
    deduped
}

fn merge_origin_paths(dest: &mut Vec<PathBuf>, origins: &[PathBuf]) {
    for origin in origins {
        if !dest.contains(origin) {
            dest.push(origin.clone());
        }
    }
}

fn imported_function_sites_for_contract(
    scope: ScopeId,
    span: Span,
    contract: &FileContract,
    trust_provided_bindings: bool,
) -> Vec<ImportedFunctionContractSite> {
    contract
        .provided_functions
        .iter()
        .cloned()
        .map(|function| ImportedFunctionContractSite {
            scope,
            span,
            certainty: function_contract_certainty(contract, &function.name),
            trust_provided_bindings,
            contract: function,
        })
        .collect()
}

fn function_contract_certainty(contract: &FileContract, name: &Name) -> ContractCertainty {
    contract
        .provided_bindings
        .iter()
        .find(|binding| binding.kind == ProvidedBindingKind::Function && binding.name == *name)
        .map(|binding| binding.certainty)
        .unwrap_or(ContractCertainty::Definite)
}

fn binding_origin_paths(contract: &FileContract, binding: &ProvidedBinding) -> Vec<PathBuf> {
    if binding.kind != ProvidedBindingKind::Function {
        return Vec::new();
    }

    contract
        .provided_functions
        .iter()
        .find(|function| function.name == binding.name)
        .map(|function| function.origin_paths.clone())
        .unwrap_or_default()
}

fn binding_for_imported_function_call(
    binding: &ProvidedBinding,
    function_certainty: ContractCertainty,
) -> ProvidedBinding {
    let certainty = match (binding.certainty, function_certainty) {
        (ContractCertainty::Definite, ContractCertainty::Definite) => ContractCertainty::Definite,
        _ => ContractCertainty::Possible,
    };
    ProvidedBinding::new(binding.name.clone(), binding.kind, certainty)
}

enum VisibleFunctionTarget<'a> {
    Local,
    Imported(&'a ImportedFunctionContractSite),
}

fn visible_imported_function_contract<'a>(
    model: &SemanticModel,
    imported_functions: &'a [ImportedFunctionContractSite],
    name: &Name,
    scope: ScopeId,
    offset: usize,
) -> Option<&'a ImportedFunctionContractSite> {
    for scope_id in model.ancestor_scopes(scope) {
        let local = lexically_visible_function_binding_in_scope(
            model.scopes(),
            model.bindings(),
            name,
            scope_id,
            scope,
            offset,
        )
        .map(|binding| {
            (
                VisibleFunctionTarget::Local,
                model.binding(binding).span.start.offset(),
            )
        });
        let imported =
            visible_imported_function_in_scope(imported_functions, name, scope_id, scope, offset)
                .map(|site| {
                    (
                        VisibleFunctionTarget::Imported(site),
                        site.span.start.offset(),
                    )
                });

        let visible = match (local, imported) {
            (Some((target, local_offset)), Some((imported_target, imported_offset))) => {
                if imported_offset > local_offset {
                    (imported_target, imported_offset)
                } else {
                    (target, local_offset)
                }
            }
            (Some(candidate), None) | (None, Some(candidate)) => candidate,
            (None, None) => continue,
        };

        return match visible.0 {
            VisibleFunctionTarget::Local => None,
            VisibleFunctionTarget::Imported(site) => Some(site),
        };
    }

    None
}

fn visible_imported_function_in_scope<'a>(
    imported_functions: &'a [ImportedFunctionContractSite],
    name: &Name,
    target_scope: ScopeId,
    call_scope: ScopeId,
    offset: usize,
) -> Option<&'a ImportedFunctionContractSite> {
    imported_functions
        .iter()
        .filter(|site| site.scope == target_scope && site.contract.name == *name)
        .filter(|site| target_scope != call_scope || site.span.start.offset() <= offset)
        .max_by_key(|site| site.span.start.offset())
}

#[derive(Debug, Clone)]
struct AstFacts {
    source_templates: FxHashMap<SpanKey, SourcePathTemplate>,
    source_templates_use_positional_args: bool,
    calls: Vec<CallInfo>,
}

#[derive(Debug, Clone)]
struct CallInfo {
    name: Name,
    scope: ScopeId,
    span: Span,
    args: Vec<Option<compact_str::CompactString>>,
}

#[derive(Debug, Clone)]
pub(crate) enum SourcePathTemplate {
    Interpolated(Vec<TemplatePart>),
}

#[derive(Debug, Clone)]
pub(crate) enum TemplatePart {
    Literal(String),
    Arg(usize),
    SourceDir,
    SourceFile,
}

fn collect_ast_facts(model: &SemanticModel) -> AstFacts {
    let mut facts = AstFacts {
        source_templates: FxHashMap::default(),
        source_templates_use_positional_args: false,
        calls: Vec::new(),
    };
    let program = model.recorded_program();
    let mut commands = program.commands().iter().collect::<Vec<_>>();
    commands.sort_by_key(|command| (command.span.start.offset(), command.span.end.offset()));

    for command in commands {
        let Some(info) = program.command_info_for_span(command.span) else {
            continue;
        };
        let Some(name) = info.static_callee.as_deref() else {
            continue;
        };
        if name.is_empty() {
            continue;
        }

        let name = Name::from(name);
        let is_source_builtin = matches!(name.as_str(), "source" | ".");
        facts.calls.push(CallInfo {
            name,
            scope: model.scope_at(command.span.start.offset()),
            span: command.span,
            args: info.static_args.to_vec(),
        });

        if is_source_builtin && let Some(template) = info.source_path_template.clone() {
            facts.source_templates_use_positional_args |=
                source_template_uses_positional_args(&template);
            facts
                .source_templates
                .insert(SpanKey::new(command.span), template);
        }
    }
    facts
}

fn source_template_uses_positional_args(template: &SourcePathTemplate) -> bool {
    match template {
        SourcePathTemplate::Interpolated(parts) => uses_positional_args(parts),
    }
}

pub(crate) fn source_path_template(
    word: &Word,
    source: &str,
    bash_runtime_vars_enabled: bool,
    zsh_runtime_vars_enabled: bool,
) -> Option<SourcePathTemplate> {
    if static_word_text(word, source).is_some() {
        return None;
    }

    source_path_template_with_resolver(
        word,
        source,
        bash_runtime_vars_enabled,
        zsh_runtime_vars_enabled,
        |_, _| None,
    )
    .map(|resolved| resolved.template)
}

pub(crate) fn assignment_source_path_template(
    word: &Word,
    source: &str,
    bash_runtime_vars_enabled: bool,
    zsh_runtime_vars_enabled: bool,
    resolve_variable_template: impl FnMut(&Name, Span) -> Option<SourcePathTemplate>,
) -> Option<SourcePathTemplate> {
    source_path_template_with_resolver(
        word,
        source,
        bash_runtime_vars_enabled,
        zsh_runtime_vars_enabled,
        resolve_variable_template,
    )
    .map(|resolved| resolved.template)
}

struct ResolvedSourcePathTemplate {
    template: SourcePathTemplate,
    ignored_root: bool,
}

fn source_path_template_with_resolver(
    word: &Word,
    source: &str,
    bash_runtime_vars_enabled: bool,
    zsh_runtime_vars_enabled: bool,
    resolve_variable_template: impl FnMut(&Name, Span) -> Option<SourcePathTemplate>,
) -> Option<ResolvedSourcePathTemplate> {
    if let Some(text) = static_word_text(word, source) {
        return (!text.is_empty()).then(|| ResolvedSourcePathTemplate {
            template: SourcePathTemplate::Interpolated(vec![TemplatePart::Literal(
                text.into_owned(),
            )]),
            ignored_root: false,
        });
    }

    let mut context = SourceTemplateContext {
        source,
        bash_runtime_vars_enabled,
        zsh_runtime_vars_enabled,
        resolve_variable_template,
    };
    let mut parts = Vec::new();
    let mut ignored_root = false;
    let mut saw_dynamic = false;

    if !collect_source_template_parts(
        &word.parts,
        &mut context,
        &mut parts,
        &mut ignored_root,
        &mut saw_dynamic,
    ) {
        return None;
    }

    (saw_dynamic && !parts.is_empty()).then_some(ResolvedSourcePathTemplate {
        template: SourcePathTemplate::Interpolated(parts),
        ignored_root,
    })
}

struct SourceTemplateContext<'a, F> {
    source: &'a str,
    bash_runtime_vars_enabled: bool,
    zsh_runtime_vars_enabled: bool,
    resolve_variable_template: F,
}

fn collect_source_template_parts<F>(
    word_parts: &[WordPartNode],
    context: &mut SourceTemplateContext<'_, F>,
    parts: &mut Vec<TemplatePart>,
    ignored_root: &mut bool,
    saw_dynamic: &mut bool,
) -> bool
where
    F: FnMut(&Name, Span) -> Option<SourcePathTemplate>,
{
    for part in word_parts {
        match &part.kind {
            WordPart::Literal(text) => {
                let text = text.as_str(context.source, part.span);
                if !text.is_empty() {
                    push_literal(parts, text.to_owned());
                }
            }
            WordPart::SingleQuoted { value, .. } => {
                let text = value.slice(context.source);
                if !text.is_empty() {
                    push_literal(parts, text.to_owned());
                }
            }
            WordPart::DoubleQuoted { parts: inner, .. } => {
                if !collect_source_template_parts(inner, context, parts, ignored_root, saw_dynamic)
                {
                    return false;
                }
            }
            WordPart::Variable(name) => {
                if let Some(index) = positional_index(name) {
                    *saw_dynamic = true;
                    parts.push(TemplatePart::Arg(index));
                } else if context.bash_runtime_vars_enabled && is_bash_source_var(name) {
                    *saw_dynamic = true;
                    parts.push(TemplatePart::SourceFile);
                } else if let Some(template) = (context.resolve_variable_template)(name, part.span)
                {
                    *saw_dynamic = true;
                    append_template_parts(parts, &template);
                } else if !*ignored_root && parts.is_empty() {
                    *ignored_root = true;
                    *saw_dynamic = true;
                } else {
                    return false;
                }
            }
            WordPart::Parameter(parameter)
                if context.bash_runtime_vars_enabled
                    && parameter_is_current_source_file(parameter, context.source) =>
            {
                *saw_dynamic = true;
                parts.push(TemplatePart::SourceFile);
            }
            WordPart::Parameter(parameter)
                if context.zsh_runtime_vars_enabled
                    && append_zsh_parameter_template(parameter, context, parts) =>
            {
                *saw_dynamic = true;
            }
            WordPart::ArrayAccess(reference)
                if context.bash_runtime_vars_enabled
                    && is_bash_source_index_ref(reference, context.source) =>
            {
                *saw_dynamic = true;
                parts.push(TemplatePart::SourceFile);
            }
            WordPart::CommandSubstitution { body, .. } => {
                if context.bash_runtime_vars_enabled
                    && let Some(template_part) = dirname_source_template_part(body, context.source)
                {
                    *saw_dynamic = true;
                    parts.push(template_part);
                } else {
                    return false;
                }
            }
            _ => return false,
        }
    }

    true
}

fn push_literal(parts: &mut Vec<TemplatePart>, text: String) {
    if let Some(TemplatePart::Literal(existing)) = parts.last_mut() {
        existing.push_str(&text);
    } else {
        parts.push(TemplatePart::Literal(text));
    }
}

fn append_template_parts(parts: &mut Vec<TemplatePart>, template: &SourcePathTemplate) {
    match template {
        SourcePathTemplate::Interpolated(template_parts) => {
            for part in template_parts {
                match part {
                    TemplatePart::Literal(text) => push_literal(parts, text.clone()),
                    TemplatePart::Arg(index) => parts.push(TemplatePart::Arg(*index)),
                    TemplatePart::SourceDir => parts.push(TemplatePart::SourceDir),
                    TemplatePart::SourceFile => parts.push(TemplatePart::SourceFile),
                }
            }
        }
    }
}

fn template_with_literal_tail(template: &SourcePathTemplate, tail: &str) -> SourcePathTemplate {
    let mut parts = Vec::new();
    append_template_parts(&mut parts, template);
    if !tail.is_empty() {
        push_literal(&mut parts, tail.to_owned());
    }
    SourcePathTemplate::Interpolated(parts)
}

fn append_zsh_parameter_template<F>(
    parameter: &ParameterExpansion,
    context: &mut SourceTemplateContext<'_, F>,
    parts: &mut Vec<TemplatePart>,
) -> bool
where
    F: FnMut(&Name, Span) -> Option<SourcePathTemplate>,
{
    let expansion_span = parameter.span;
    let ParameterExpansionSyntax::Zsh(parameter) = &parameter.syntax else {
        return false;
    };
    let Some(template) = zsh_parameter_source_template(parameter, expansion_span, context) else {
        return false;
    };
    append_template_parts(parts, &template);
    true
}

fn zsh_parameter_source_template<F>(
    parameter: &ZshParameterExpansion,
    expansion_span: Span,
    context: &mut SourceTemplateContext<'_, F>,
) -> Option<SourcePathTemplate>
where
    F: FnMut(&Name, Span) -> Option<SourcePathTemplate>,
{
    let mut template = match &parameter.target {
        ZshExpansionTarget::Reference(reference)
            if reference.subscript.is_none() && reference.name.as_str() == "0" =>
        {
            SourcePathTemplate::Interpolated(vec![TemplatePart::SourceFile])
        }
        ZshExpansionTarget::Reference(reference) => {
            let span = if reference.name_span.start.offset() == 0
                && reference.name_span.end.offset() == 0
            {
                expansion_span
            } else {
                reference.name_span
            };
            (context.resolve_variable_template)(&reference.name, span)?
        }
        ZshExpansionTarget::Nested(nested_expansion) => {
            let ParameterExpansionSyntax::Zsh(nested) = &nested_expansion.syntax else {
                return None;
            };
            zsh_parameter_source_template(nested, nested_expansion.span, context)?
        }
        ZshExpansionTarget::Empty if zsh_empty_prompt_current_script(parameter, context.source) => {
            SourcePathTemplate::Interpolated(vec![TemplatePart::SourceFile])
        }
        ZshExpansionTarget::Word(_) | ZshExpansionTarget::Empty => return None,
    };

    for modifier in zsh_path_modifier_names(parameter, context.source)? {
        template = match modifier {
            'a' | 'A' => template,
            'h' => dirname_source_path_template(&template)?,
            _ => return None,
        };
    }

    Some(template)
}

fn zsh_path_modifier_names(parameter: &ZshParameterExpansion, source: &str) -> Option<Vec<char>> {
    let mut names = parameter
        .modifiers
        .iter()
        .filter(|modifier| modifier.name != '%')
        .map(|modifier| modifier.name)
        .collect::<Vec<_>>();

    if let Some(ZshExpansionOperation::Unknown { text, .. }) = &parameter.operation {
        let text = text.slice(source);
        let suffix = text.strip_prefix(':')?;
        if suffix.is_empty() {
            return None;
        }
        for modifier in suffix.split(':') {
            let mut chars = modifier.chars();
            let name = chars.next()?;
            if chars.next().is_some() {
                return None;
            }
            names.push(name);
        }
    }

    Some(names)
}

fn zsh_empty_prompt_current_script(parameter: &ZshParameterExpansion, source: &str) -> bool {
    if !matches!(parameter.target, ZshExpansionTarget::Empty) {
        return false;
    }
    if !parameter
        .modifiers
        .iter()
        .any(|modifier| modifier.name == '%')
    {
        return false;
    }
    matches!(
        &parameter.operation,
        Some(ZshExpansionOperation::Defaulting {
            kind: ZshDefaultingOp::UseDefault,
            operand,
            ..
        }) if matches!(operand.slice(source), "%x" | "%N")
    )
}

fn dirname_source_path_template(template: &SourcePathTemplate) -> Option<SourcePathTemplate> {
    match template {
        SourcePathTemplate::Interpolated(parts) => match parts.as_slice() {
            [TemplatePart::SourceFile] => Some(SourcePathTemplate::Interpolated(vec![
                TemplatePart::SourceDir,
            ])),
            _ => None,
        },
    }
}

fn positional_index(name: &Name) -> Option<usize> {
    name.as_str().parse().ok()
}

fn is_bash_source_var(name: &Name) -> bool {
    name.as_str() == "BASH_SOURCE"
}

fn parameter_is_current_source_file(parameter: &ParameterExpansion, source: &str) -> bool {
    match &parameter.syntax {
        ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) => {
            is_current_source_reference(reference, source)
        }
        ParameterExpansionSyntax::Bourne(
            BourneParameterExpansion::Length { .. }
            | BourneParameterExpansion::Indices { .. }
            | BourneParameterExpansion::Indirect { .. }
            | BourneParameterExpansion::PrefixMatch { .. }
            | BourneParameterExpansion::Slice { .. }
            | BourneParameterExpansion::Operation { .. }
            | BourneParameterExpansion::Transformation { .. },
        )
        | ParameterExpansionSyntax::Zsh(_) => false,
    }
}

fn is_current_source_reference(reference: &VarRef, source: &str) -> bool {
    is_bash_source_var(&reference.name)
        && reference
            .subscript
            .as_ref()
            .is_none_or(|subscript| subscript_is_semantic_zero(subscript, source))
}

fn is_bash_source_index_ref(reference: &VarRef, source: &str) -> bool {
    is_bash_source_var(&reference.name)
        && reference
            .subscript
            .as_ref()
            .is_some_and(|subscript| subscript_is_semantic_zero(subscript, source))
}

fn subscript_is_semantic_zero(subscript: &shuck_ast::Subscript, source: &str) -> bool {
    subscript
        .arithmetic_ast
        .as_ref()
        .is_some_and(|expr| arithmetic_expr_is_semantic_zero(expr, source))
}

fn arithmetic_expr_is_semantic_zero(expr: &ArithmeticExprNode, source: &str) -> bool {
    match &expr.kind {
        ArithmeticExpr::Number(text) => shell_zero_literal(text.slice(source)),
        ArithmeticExpr::ShellWord(word) => word_is_semantic_zero(word, source),
        ArithmeticExpr::Parenthesized { expression } => {
            arithmetic_expr_is_semantic_zero(expression, source)
        }
        ArithmeticExpr::Unary { expr, .. } => arithmetic_expr_is_semantic_zero(expr, source),
        _ => false,
    }
}

fn word_is_semantic_zero(word: &Word, source: &str) -> bool {
    matches!(
        word.parts.as_slice(),
        [part] if match &part.kind {
            WordPart::Literal(text) => shell_zero_literal(text.as_str(source, part.span)),
            WordPart::SingleQuoted { value, .. } => shell_zero_literal(value.slice(source)),
            WordPart::DoubleQuoted { parts, .. } => matches!(
                parts.as_slice(),
                [part] if word_part_is_semantic_zero(&part.kind, part.span, source)
            ),
            WordPart::ArithmeticExpansion {
                expression_ast: Some(expr),
                ..
            } => arithmetic_expr_is_semantic_zero(expr, source),
            _ => false,
        }
    )
}

fn word_part_is_semantic_zero(part: &WordPart, span: Span, source: &str) -> bool {
    match part {
        WordPart::Literal(text) => shell_zero_literal(text.as_str(source, span)),
        WordPart::SingleQuoted { value, .. } => shell_zero_literal(value.slice(source)),
        WordPart::DoubleQuoted { parts, .. } => matches!(
            parts.as_slice(),
            [part] if word_part_is_semantic_zero(&part.kind, part.span, source)
        ),
        WordPart::ArithmeticExpansion {
            expression_ast: Some(expr),
            ..
        } => arithmetic_expr_is_semantic_zero(expr, source),
        _ => false,
    }
}

fn shell_zero_literal(text: &str) -> bool {
    let text = text.trim();
    if text.is_empty() {
        return false;
    }

    let digits = text
        .strip_prefix('+')
        .or_else(|| text.strip_prefix('-'))
        .unwrap_or(text);
    if digits.is_empty() {
        return false;
    }

    if let Some((base, value)) = digits.split_once('#') {
        return base.parse::<u32>().is_ok_and(|base| {
            (2..=64).contains(&base) && !value.is_empty() && value.chars().all(|ch| ch == '0')
        });
    }

    let digits = digits
        .strip_prefix("0x")
        .or_else(|| digits.strip_prefix("0X"))
        .unwrap_or(digits);
    !digits.is_empty() && digits.chars().all(|ch| ch == '0')
}

fn dirname_source_template_part(commands: &StmtSeq, source: &str) -> Option<TemplatePart> {
    let [stmt] = commands.as_slice() else {
        return None;
    };
    let Command::Simple(command) = &stmt.command else {
        return None;
    };
    if stmt.negated
        || !stmt.redirects.is_empty()
        || !command.assignments.is_empty()
        || command.args.len() != 1
    {
        return None;
    }
    if static_word_text(&command.name, source).as_deref() != Some("dirname") {
        return None;
    }
    current_source_file_word(&command.args[0], source).then_some(TemplatePart::SourceDir)
}

fn current_source_file_word(word: &Word, source: &str) -> bool {
    matches!(
        word.parts.as_slice(),
        [part] if is_current_source_part(&part.kind, source)
    )
}

fn is_current_source_part(part: &WordPart, source: &str) -> bool {
    match part {
        WordPart::Variable(name) => is_bash_source_var(name),
        WordPart::Parameter(parameter) => parameter_is_current_source_file(parameter, source),
        WordPart::ArrayAccess(reference) => is_bash_source_index_ref(reference, source),
        WordPart::DoubleQuoted { parts, .. } => {
            matches!(parts.as_slice(), [part] if is_current_source_part(&part.kind, source))
        }
        _ => false,
    }
}

fn source_candidates(
    kind: &SourceRefKind,
    template: Option<&SourcePathTemplate>,
    call_args: Option<&[Vec<Option<compact_str::CompactString>>]>,
    source_path: &Path,
) -> Vec<String> {
    match kind {
        SourceRefKind::DirectiveDevNull => Vec::new(),
        SourceRefKind::Literal(path) | SourceRefKind::Directive(path) => vec![path.to_string()],
        SourceRefKind::Dynamic | SourceRefKind::SingleVariableStaticTail { .. } => {
            source_candidates_from_template(template, call_args, source_path)
        }
    }
}

fn source_candidates_from_template(
    template: Option<&SourcePathTemplate>,
    call_args: Option<&[Vec<Option<compact_str::CompactString>>]>,
    source_path: &Path,
) -> Vec<String> {
    let Some(template) = template else {
        return Vec::new();
    };

    match template {
        SourcePathTemplate::Interpolated(parts) => {
            if uses_positional_args(parts) {
                call_args
                    .into_iter()
                    .flatten()
                    .filter_map(|args| render_template_candidate(parts, args, source_path))
                    .collect()
            } else {
                render_template_candidate(parts, &[], source_path)
                    .into_iter()
                    .collect()
            }
        }
    }
}

fn local_helper_command_candidate(name: &Name) -> Option<String> {
    let name = name.as_str();
    // Treat sibling shell-script invocations like helper reads so globals used
    // across a script suite stay live, matching the large-corpus compatibility
    // expectation for module-style shell projects.
    (!matches!(name, "source" | ".") && looks_like_local_helper_command(name))
        .then(|| name.to_owned())
}

fn looks_like_local_helper_command(name: &str) -> bool {
    name.contains('/') || name.ends_with(".sh")
}

fn unresolved_zsh_reply_bindings_for_call(
    source_path: &Path,
    shell_profile: &ShellProfile,
    name: &Name,
) -> Option<[ProvidedBinding; 2]> {
    if shell_profile.dialect != ParseShellDialect::Zsh
        || !looks_like_zsh_runtime_path(source_path)
        || !looks_like_zsh_reply_helper_command(name.as_str())
    {
        return None;
    }

    Some([
        ProvidedBinding::new(
            Name::from("REPLY"),
            ProvidedBindingKind::Variable,
            ContractCertainty::Definite,
        ),
        ProvidedBinding::new(
            Name::from("reply"),
            ProvidedBindingKind::Variable,
            ContractCertainty::Definite,
        ),
    ])
}

fn looks_like_zsh_reply_helper_command(name: &str) -> bool {
    !matches!(name, "." | ".." | "source")
        && (name.starts_with('.') || name.contains('_') || name.contains(':'))
}

fn looks_like_zsh_runtime_path(path: &Path) -> bool {
    let lower = path_to_template_string(path).to_ascii_lowercase();
    let dotfile_shape = lower.split('/').any(|component| {
        matches!(
            component,
            ".zshrc"
                | "zshrc"
                | ".zshenv"
                | "zshenv"
                | ".zprofile"
                | "zprofile"
                | ".zlogin"
                | "zlogin"
                | ".zlogout"
                | "zlogout"
                | "zdot"
        )
    }) || lower.contains("/zsh/config/")
        || lower.contains("/zsh/configs/");
    dotfile_shape
        || [
            "/completion/",
            "/completions/",
            "/functions/",
            "/highlighters/",
            "/lib/",
            "/modules/",
            "/plugins/",
            "/plugin/",
            "/themes/",
            ".plugin.zsh",
            ".theme.zsh",
            "/zsh-autosuggestions/",
            "/zsh-syntax-highlighting/",
        ]
        .iter()
        .any(|pattern| lower.contains(pattern))
}

fn uses_positional_args(parts: &[TemplatePart]) -> bool {
    parts
        .iter()
        .any(|part| matches!(part, TemplatePart::Arg(_)))
}

fn render_template_candidate(
    parts: &[TemplatePart],
    args: &[Option<compact_str::CompactString>],
    source_path: &Path,
) -> Option<String> {
    let mut rendered = String::new();
    for part in parts {
        match part {
            TemplatePart::Literal(text) => rendered.push_str(text),
            TemplatePart::Arg(index) => {
                let value = args.get(index.saturating_sub(1))?.as_ref()?;
                rendered.push_str(value);
            }
            TemplatePart::SourceDir => {
                let value = path_to_template_string(source_path.parent()?);
                rendered.push_str(&value);
            }
            TemplatePart::SourceFile => {
                let value = path_to_template_string(source_path);
                rendered.push_str(&value);
            }
        }
    }

    let trimmed = rendered.trim();
    if trimmed.is_empty() {
        return None;
    }

    let source_derived = parts
        .iter()
        .any(|part| matches!(part, TemplatePart::SourceDir | TemplatePart::SourceFile));
    if source_derived && Path::new(trimmed).is_absolute() {
        return Some(trimmed.to_owned());
    }

    let normalized = trimmed
        .trim_start_matches("./")
        .trim_start_matches('/')
        .to_owned();
    (!normalized.is_empty()).then_some(normalized)
}

fn path_to_template_string(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

fn resolve_literal_call_args_by_scope(
    model: &SemanticModel,
    calls: &[CallInfo],
) -> FxHashMap<ScopeId, Vec<Vec<Option<compact_str::CompactString>>>> {
    call_payloads_by_callee_scope(
        &model.function_binding_lookup(),
        &model.recorded_program().function_body_scopes,
        calls.iter().map(|call| {
            (
                &call.name,
                call.scope,
                call.span.start.offset(),
                call.args.clone(),
            )
        }),
    )
}

fn resolve_helper_paths(
    source_path: &Path,
    candidate: &str,
    context: &SourceClosureLookupContext<'_>,
) -> HelperPathResolution {
    for candidate_path in candidate_path_variants(candidate) {
        if candidate_path.is_absolute() {
            if candidate_path.is_file() {
                return HelperPathResolution {
                    paths: vec![candidate_path],
                    plugin_resolved: false,
                };
            }
            continue;
        }

        let Some(base_dir) = source_path.parent() else {
            return HelperPathResolution::default();
        };

        let direct = base_dir.join(&candidate_path);
        if direct.is_file() {
            return HelperPathResolution {
                paths: vec![direct],
                plugin_resolved: false,
            };
        }
    }

    if context.shell_profile.dialect == ParseShellDialect::Zsh
        && let Some(plugin_paths) = context.plugin_resolver.map(|resolver| {
            resolver
                .resolve_source_path(source_path, candidate)
                .into_iter()
                .filter(|path| path.is_file())
                .collect::<Vec<_>>()
        })
        && !plugin_paths.is_empty()
    {
        return HelperPathResolution {
            paths: plugin_paths,
            plugin_resolved: true,
        };
    }

    // A candidate names one intended file: take the first (highest-precedence)
    // root match rather than importing every root that happens to contain it.
    let paths = context
        .source_path_resolver
        .into_iter()
        .flat_map(|resolver| resolver.resolve_candidate_paths(source_path, candidate))
        .find(|path| path.is_file())
        .into_iter()
        .collect();
    HelperPathResolution {
        paths,
        plugin_resolved: false,
    }
}

fn resolve_helper_paths_cached(
    source_path: &Path,
    candidate: &str,
    context: &SourceClosureLookupContext<'_>,
) -> HelperPathResolution {
    let key = HelperPathResolutionKey {
        source_path: source_path.to_path_buf(),
        candidate: candidate.into(),
    };
    if let Some(paths) = context.resolved_helper_paths.borrow().get(&key) {
        return paths.clone();
    }

    let paths = resolve_helper_paths(source_path, candidate, context);
    context
        .resolved_helper_paths
        .borrow_mut()
        .insert(key, paths.clone());
    paths
}

fn candidate_path_variants(candidate: &str) -> Vec<PathBuf> {
    #[cfg(not(windows))]
    let mut variants = vec![PathBuf::from(candidate)];
    #[cfg(windows)]
    let mut variants = vec![PathBuf::from(candidate)];
    #[cfg(windows)]
    if candidate.starts_with(r"\\?\") && candidate.contains('/') {
        // Windows canonicalize() can produce verbatim paths, which do not accept
        // forward slashes once we stitch in a Bash-style "/helper.bash" suffix.
        let normalized = PathBuf::from(candidate.replace('/', "\\"));
        if !variants.contains(&normalized) {
            variants.push(normalized);
        }
    }
    let normalized = lexical_normalize_path(Path::new(candidate));
    if !variants.contains(&normalized) {
        variants.push(normalized.clone());
    }
    variants
}

fn lexical_normalize_path(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();
    let mut normal_depth = 0usize;
    let mut absolute_prefix = false;
    for component in path.components() {
        match component {
            Component::CurDir => {}
            Component::ParentDir => {
                if normal_depth > 0 {
                    normalized.pop();
                    normal_depth -= 1;
                } else if !absolute_prefix {
                    normalized.push(component.as_os_str());
                }
            }
            Component::Prefix(_) | Component::RootDir => {
                normalized.push(component.as_os_str());
                absolute_prefix = true;
                normal_depth = 0;
            }
            Component::Normal(_) => {
                normalized.push(component.as_os_str());
                normal_depth += 1;
            }
        }
    }
    normalized
}

fn summarize_helper(
    path: &Path,
    summaries: &mut FxHashMap<HelperSummaryKey, FileContract>,
    active: &mut FxHashSet<HelperSummaryKey>,
    context: &SourceClosureLookupContext<'_>,
) -> FileContract {
    let requested_path = path.to_path_buf();
    context
        .dependency_paths
        .borrow_mut()
        .insert(requested_path.clone());
    let requested_key = HelperSummaryKey {
        path: requested_path.clone(),
        shell_profile: ShellProfileKey::from_profile(&context.shell_profile),
    };
    if let Some(summary) = summaries.get(&requested_key) {
        return summary.clone();
    }

    let canonical_path = fs::canonicalize(path).unwrap_or_else(|_| requested_path.clone());
    let canonical_requested_key = HelperSummaryKey {
        path: canonical_path.clone(),
        shell_profile: ShellProfileKey::from_profile(&context.shell_profile),
    };
    if let Some(summary) = summaries.get(&canonical_requested_key) {
        let summary = summary.clone();
        summaries.insert(requested_key, summary.clone());
        return summary;
    }

    let Ok(source) = fs::read_to_string(&canonical_path) else {
        return FileContract::default();
    };
    let shell_profile = helper_shell_profile(&source, &canonical_path, &context.shell_profile);
    let key = HelperSummaryKey {
        path: canonical_path.clone(),
        shell_profile: ShellProfileKey::from_profile(&shell_profile),
    };
    if let Some(summary) = summaries.get(&key) {
        let summary = summary.clone();
        summaries.insert(canonical_requested_key, summary.clone());
        summaries.insert(requested_key, summary.clone());
        return summary;
    }
    if !active.insert(key.clone()) {
        return FileContract::default();
    }

    let summary = summarize_helper_uncached(
        &canonical_path,
        &source,
        shell_profile,
        summaries,
        active,
        context,
    );
    active.remove(&key);
    summaries.insert(key, summary.clone());
    summaries.insert(canonical_requested_key, summary.clone());
    summaries.insert(requested_key, summary.clone());
    summary
}

fn summarize_helper_uncached(
    path: &Path,
    source: &str,
    shell_profile: ShellProfile,
    summaries: &mut FxHashMap<HelperSummaryKey, FileContract>,
    active: &mut FxHashSet<HelperSummaryKey>,
    context: &SourceClosureLookupContext<'_>,
) -> FileContract {
    let output = Parser::with_profile(source, shell_profile.clone()).parse();
    if output.is_err() {
        return FileContract::default();
    }
    let indexer = Indexer::new(source, &output);
    let mut observer = crate::NoopTraversalObserver;
    let mut file_entry_contract_collector = context
        .file_entry_contract_collector_factory
        .and_then(|factory| factory.collector_for_file(source, Some(path), &shell_profile));
    let mut semantic = build_semantic_model_base(
        &output.file,
        source,
        &indexer,
        &mut observer,
        Some(path),
        Some(shell_profile.clone()),
        file_entry_contract_collector
            .as_mut()
            .map(|collector| &mut **collector as &mut dyn FileEntryContractCollector),
    );
    if let Some(contract) = file_entry_contract_collector
        .as_ref()
        .and_then(|collector| collector.finish())
    {
        semantic.apply_file_entry_contract(contract, &output.file);
    }
    let collected = collect_source_closure_contracts_with_cache(
        &semantic,
        &output.file,
        source,
        path,
        summaries,
        active,
        &SourceClosureLookupContext {
            source_path_resolver: context.source_path_resolver,
            plugin_resolver: context.plugin_resolver,
            file_entry_contract_collector_factory: context.file_entry_contract_collector_factory,
            analyzed_paths: None,
            shell_profile,
            resolved_helper_paths: RefCell::new(FxHashMap::default()),
            dependency_paths: RefCell::new(FxHashSet::default()),
        },
    );
    semantic.apply_source_contracts(collected.clone());
    let analysis = semantic.analysis();
    let include_root_provided_bindings =
        scope_has_provided_binding_candidates(&semantic, ScopeId(0));
    let include_root_provided_functions =
        scope_has_provided_function_candidates(&semantic, ScopeId(0));

    let mut contract = summarize_scope_body_contract(
        &semantic,
        &analysis,
        ScopeId(0),
        &collected.synthetic_reads,
        include_root_provided_bindings,
    );
    let provided_functions = if include_root_provided_functions {
        analysis.summarize_scope_provided_functions(ScopeId(0))
    } else {
        Vec::new()
    };
    for binding in &provided_functions {
        contract.add_provided_binding(binding.clone());
    }
    if !provided_functions.is_empty() {
        for function in build_scope_function_contracts(
            path,
            &semantic,
            &analysis,
            ScopeId(0),
            &collected.synthetic_reads,
            &collected.imported_functions,
            &provided_functions,
        ) {
            contract.add_provided_function(function);
        }
    }
    if semantic.shell_profile().dialect == ParseShellDialect::Zsh {
        let facts = collect_ast_facts(&semantic);
        for name in deferred_zsh_entrypoint_required_reads(
            &semantic,
            &analysis,
            &facts,
            source,
            ScopeId(0),
            &collected.synthetic_reads,
        ) {
            contract.add_required_read(name);
        }
    }
    contract
}

fn helper_shell_profile(source: &str, path: &Path, inherited: &ShellProfile) -> ShellProfile {
    infer_explicit_parse_dialect_from_source(source, Some(path))
        .map(ShellProfile::native)
        .unwrap_or_else(|| inherited.clone())
}

fn summarize_scope_body_contract(
    semantic: &SemanticModel,
    analysis: &crate::SemanticAnalysis<'_>,
    scope: ScopeId,
    synthetic_reads: &[SyntheticRead],
    include_provided_bindings: bool,
) -> FileContract {
    let scope_members = scope_members_excluding_functions(semantic.scopes(), scope);
    let mut contract = FileContract::default();
    for reference in semantic.unresolved_references() {
        let reference = semantic.reference(*reference);
        if scope_members.contains(&reference.scope) {
            contract.add_required_read(reference.name.clone());
        }
    }
    for read in synthetic_reads {
        if scope_members.contains(&read.scope) {
            contract.add_required_read(read.name.clone());
        }
    }
    if include_provided_bindings {
        for binding in analysis.summarize_scope_provided_bindings(scope) {
            contract.add_provided_binding(binding);
        }
    }
    contract
}

fn scope_has_provided_binding_candidates(semantic: &SemanticModel, scope: ScopeId) -> bool {
    semantic.bindings().iter().any(|binding| {
        binding.scope == scope
            && !binding.attributes.contains(BindingAttributes::LOCAL)
            && binding_initializes_name(binding).is_some()
    })
}

fn scope_has_provided_function_candidates(semantic: &SemanticModel, scope: ScopeId) -> bool {
    semantic
        .bindings()
        .iter()
        .any(|binding| binding.scope == scope && function_binding_certainty(binding).is_some())
}

fn build_scope_function_contracts(
    origin_path: &Path,
    semantic: &SemanticModel,
    analysis: &crate::SemanticAnalysis<'_>,
    scope: ScopeId,
    synthetic_reads: &[SyntheticRead],
    imported_functions: &[ImportedFunctionContractSite],
    provided_functions: &[ProvidedBinding],
) -> Vec<FunctionContract> {
    let function_scopes = semantic
        .scopes()
        .iter()
        .filter_map(|candidate| {
            (candidate.parent == Some(scope))
                .then_some(candidate)
                .and_then(|candidate| match &candidate.kind {
                    ScopeKind::Function(FunctionScopeKind::Named(names)) => {
                        Some((candidate.id, names.clone()))
                    }
                    _ => None,
                })
        })
        .collect::<Vec<_>>();

    let mut local_contracts_by_scope = FxHashMap::default();
    let mut contracts_by_name: FxHashMap<Name, Vec<FunctionContract>> = FxHashMap::default();

    for (function_scope, names) in function_scopes {
        let body_contract = local_contracts_by_scope
            .entry(function_scope)
            .or_insert_with(|| {
                summarize_scope_body_contract(
                    semantic,
                    analysis,
                    function_scope,
                    synthetic_reads,
                    scope_has_provided_binding_candidates(semantic, function_scope),
                )
            })
            .clone();
        for name in names {
            let mut function_contract = FunctionContract::new(name.clone());
            function_contract.add_origin_path(origin_path.to_path_buf());
            for read in &body_contract.required_reads {
                function_contract.add_required_read(read.clone());
            }
            for binding in &body_contract.provided_bindings {
                function_contract.add_provided_binding(binding.clone());
            }
            contracts_by_name
                .entry(name)
                .or_default()
                .push(function_contract);
        }
    }

    for imported in imported_functions.iter().filter(|site| site.scope == scope) {
        contracts_by_name
            .entry(imported.contract.name.clone())
            .or_default()
            .push(imported.contract.clone());
    }

    let mut functions = Vec::new();
    for binding in provided_functions {
        if let Some(contracts) = contracts_by_name.get(&binding.name)
            && let Some(function) = FunctionContract::merge_candidate_contracts(contracts)
        {
            functions.push(function);
        }
    }
    functions.sort_by(|left, right| left.name.as_str().cmp(right.name.as_str()));
    functions
}

fn scope_members_excluding_functions(scopes: &[crate::Scope], root: ScopeId) -> FxHashSet<ScopeId> {
    let mut members = FxHashSet::default();
    let mut stack = vec![root];
    while let Some(scope_id) = stack.pop() {
        if !members.insert(scope_id) {
            continue;
        }
        for scope in scopes {
            if scope.parent == Some(scope_id) && !matches!(scope.kind, ScopeKind::Function(_)) {
                stack.push(scope.id);
            }
        }
    }
    members
}

#[cfg(test)]
mod tests {
    use super::*;
    #[cfg(windows)]
    use std::path::Path;

    use shuck_parser::parser::ShellDialect;
    #[cfg(windows)]
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn zsh_operation_operands_are_walked_when_collecting_ast_facts() {
        let source = "print ${(m)foo#$(printf '%s' \"$needle\")} ${(S)foo/$pattern/$(dirname \"$1\")} ${(m)foo:$(source \"$2\"):${length}}\n";
        let output = Parser::with_dialect(source, ShellDialect::Zsh)
            .parse()
            .unwrap();
        let indexer = Indexer::new(source, &output);
        let model = SemanticModel::build(&output.file, source, &indexer);
        let facts = collect_ast_facts(&model);
        let call_names = facts
            .calls
            .iter()
            .map(|call| call.name.to_string())
            .collect::<Vec<_>>();

        assert!(call_names.iter().any(|name| name == "printf"));
        assert!(call_names.iter().any(|name| name == "dirname"));
        assert!(call_names.iter().any(|name| name == "source"));
    }

    #[test]
    fn candidate_path_variants_preserve_leading_parent_segments() {
        let variants = candidate_path_variants("../../helper.sh");

        assert_eq!(variants[0], PathBuf::from("../../helper.sh"));
        assert!(variants.iter().all(|path| path != Path::new("helper.sh")));
    }

    #[test]
    fn wrapper_commands_keep_inner_call_and_source_template_facts() {
        let source = "\
#!/bin/bash
time . \"$1\"
coproc loader { . \"$2\"; }
";
        let output = Parser::with_dialect(source, ShellDialect::Bash)
            .parse()
            .unwrap();
        let indexer = Indexer::new(source, &output);
        let model = SemanticModel::build(&output.file, source, &indexer);
        let facts = collect_ast_facts(&model);
        let source_call_count = facts
            .calls
            .iter()
            .filter(|call| call.name.as_str() == ".")
            .count();

        assert_eq!(source_call_count, 2);
        assert_eq!(facts.source_templates.len(), 2);
    }

    #[test]
    fn resolve_literal_call_args_by_scope_uses_visible_parent_function_bindings() {
        let source = "\
outer() {
  inner() { load_helper ./helper.sh; }
  load_helper() { . \"$1\"; }
  inner
}
";
        let output = Parser::new(source).parse().unwrap();
        let indexer = Indexer::new(source, &output);
        let model = SemanticModel::build(&output.file, source, &indexer);
        let facts = collect_ast_facts(&model);
        let args_by_scope = resolve_literal_call_args_by_scope(&model, &facts.calls);
        let name = Name::from("load_helper");
        let binding = model.function_definitions(&name)[0];
        let scope = model
            .analysis()
            .function_scope_for_binding(binding)
            .expect("expected function scope");

        assert_eq!(
            args_by_scope.get(&scope),
            Some(&vec![vec![Some(compact_str::CompactString::from(
                "./helper.sh"
            ))]])
        );
    }

    #[test]
    fn resolve_literal_call_args_by_scope_tracks_wrapper_expanded_calls() {
        let source = "\
load_helper() { . \"$1\"; }
noglob load_helper ./helper.sh
";
        let output = Parser::new(source).parse().unwrap();
        let indexer = Indexer::new(source, &output);
        let model = SemanticModel::build(&output.file, source, &indexer);
        let facts = collect_ast_facts(&model);
        let args_by_scope = resolve_literal_call_args_by_scope(&model, &facts.calls);
        let name = Name::from("load_helper");
        let binding = model.function_definitions(&name)[0];
        let scope = model
            .analysis()
            .function_scope_for_binding(binding)
            .expect("expected function scope");

        assert_eq!(
            args_by_scope.get(&scope),
            Some(&vec![vec![Some(compact_str::CompactString::from(
                "./helper.sh"
            ))]])
        );
    }

    #[test]
    fn resolve_literal_call_args_by_scope_ignores_calls_before_function_definition() {
        let source = "\
load_helper ./helper.sh
load_helper() { . \"$1\"; }
";
        let output = Parser::new(source).parse().unwrap();
        let indexer = Indexer::new(source, &output);
        let model = SemanticModel::build(&output.file, source, &indexer);
        let facts = collect_ast_facts(&model);
        let args_by_scope = resolve_literal_call_args_by_scope(&model, &facts.calls);
        let name = Name::from("load_helper");
        let binding = model.function_definitions(&name)[0];
        let scope = model
            .analysis()
            .function_scope_for_binding(binding)
            .expect("expected function scope");

        assert_eq!(args_by_scope.get(&scope), None);
    }

    #[test]
    fn shell_precommand_wrappers_do_not_create_source_template_facts() {
        let source = "\
#!/bin/bash
command . \"$1\"
builtin source \"$2\"
noglob source \"$3\"
";
        let output = Parser::with_dialect(source, ShellDialect::Bash)
            .parse()
            .unwrap();
        let indexer = Indexer::new(source, &output);
        let model = SemanticModel::build(&output.file, source, &indexer);
        let facts = collect_ast_facts(&model);

        assert!(facts.source_templates.is_empty());
    }

    #[test]
    fn summarize_helper_reuses_request_profile_cache_hit_before_reading() {
        let temp = tempdir().unwrap();
        let helper = temp.path().join("helper");
        std::fs::write(&helper, "#!/bin/zsh\nloaded_value=ok\n").unwrap();
        let canonical_helper = std::fs::canonicalize(&helper).unwrap();

        let context = SourceClosureLookupContext {
            source_path_resolver: None,
            plugin_resolver: None,
            file_entry_contract_collector_factory: None,
            analyzed_paths: None,
            shell_profile: ShellProfile::native(ParseShellDialect::Bash),
            resolved_helper_paths: RefCell::new(FxHashMap::default()),
            dependency_paths: RefCell::new(FxHashSet::default()),
        };
        let mut summaries = FxHashMap::default();
        let mut active = FxHashSet::default();

        let first = summarize_helper(&canonical_helper, &mut summaries, &mut active, &context);
        assert!(first.provided_bindings.iter().any(|binding| {
            binding.name.as_str() == "loaded_value"
                && binding.kind == ProvidedBindingKind::Variable
                && binding.certainty == ContractCertainty::Definite
        }));

        std::fs::remove_file(&helper).unwrap();

        let second = summarize_helper(&canonical_helper, &mut summaries, &mut active, &context);
        assert_eq!(second, first);
    }

    #[test]
    fn summarize_helper_without_export_candidates_keeps_required_reads_only() {
        let temp = tempdir().unwrap();
        let helper = temp.path().join("helper.sh");
        std::fs::write(&helper, "printf '%s\\n' \"$flag\"\n").unwrap();
        let canonical_helper = std::fs::canonicalize(&helper).unwrap();

        let context = SourceClosureLookupContext {
            source_path_resolver: None,
            plugin_resolver: None,
            file_entry_contract_collector_factory: None,
            analyzed_paths: None,
            shell_profile: ShellProfile::native(ParseShellDialect::Bash),
            resolved_helper_paths: RefCell::new(FxHashMap::default()),
            dependency_paths: RefCell::new(FxHashSet::default()),
        };
        let mut summaries = FxHashMap::default();
        let mut active = FxHashSet::default();

        let summary = summarize_helper(&canonical_helper, &mut summaries, &mut active, &context);

        assert_eq!(summary.required_reads, vec![Name::from("flag")]);
        assert!(summary.provided_bindings.is_empty());
        assert!(summary.provided_functions.is_empty());
    }

    #[test]
    fn summarize_helper_keeps_reexported_sourced_functions() {
        let temp = tempdir().unwrap();
        let outer = temp.path().join("outer.sh");
        let inner = temp.path().join("inner.sh");
        std::fs::write(&outer, ". ./inner.sh\n").unwrap();
        std::fs::write(
            &inner,
            "\
set_flag() {
  flag=1
}
",
        )
        .unwrap();
        let canonical_outer = std::fs::canonicalize(&outer).unwrap();

        let context = SourceClosureLookupContext {
            source_path_resolver: None,
            plugin_resolver: None,
            file_entry_contract_collector_factory: None,
            analyzed_paths: None,
            shell_profile: ShellProfile::native(ParseShellDialect::Bash),
            resolved_helper_paths: RefCell::new(FxHashMap::default()),
            dependency_paths: RefCell::new(FxHashSet::default()),
        };
        let mut summaries = FxHashMap::default();
        let mut active = FxHashSet::default();

        let summary = summarize_helper(&canonical_outer, &mut summaries, &mut active, &context);

        assert!(summary.provided_bindings.iter().any(|binding| {
            binding.name.as_str() == "set_flag"
                && binding.kind == ProvidedBindingKind::Function
                && binding.certainty == ContractCertainty::Definite
        }));
        assert!(summary.provided_functions.iter().any(|function| {
            function.name.as_str() == "set_flag"
                && function.provided_bindings.iter().any(|binding| {
                    binding.name.as_str() == "flag"
                        && binding.kind == ProvidedBindingKind::Variable
                        && binding.certainty == ContractCertainty::Definite
                })
        }));
    }

    #[cfg(unix)]
    #[test]
    fn summarize_helper_tracks_requested_symlink_path_as_dependency() {
        use std::os::unix::fs::symlink;

        let temp = tempdir().unwrap();
        let helper_target = temp.path().join("helper-target");
        let helper_link = temp.path().join("helper-link");
        std::fs::write(&helper_target, "#!/bin/zsh\nloaded_value=ok\n").unwrap();
        symlink(&helper_target, &helper_link).unwrap();

        let context = SourceClosureLookupContext {
            source_path_resolver: None,
            plugin_resolver: None,
            file_entry_contract_collector_factory: None,
            analyzed_paths: None,
            shell_profile: ShellProfile::native(ParseShellDialect::Bash),
            resolved_helper_paths: RefCell::new(FxHashMap::default()),
            dependency_paths: RefCell::new(FxHashSet::default()),
        };
        let mut summaries = FxHashMap::default();
        let mut active = FxHashSet::default();

        summarize_helper(&helper_link, &mut summaries, &mut active, &context);

        let dependency_paths = context.dependency_paths.borrow();
        assert!(dependency_paths.contains(&helper_link));
        assert!(!dependency_paths.contains(&std::fs::canonicalize(&helper_target).unwrap()));
    }

    #[test]
    fn dedup_plugin_requests_preserves_first_request_order_when_replacing_duplicates() {
        let mut alpha_implicit = PluginRequest {
            framework: PluginFramework::OhMyZsh,
            kind: PluginRequestKind::Plugin,
            name: "alpha".to_owned(),
            span: Span::new(),
            explicit: false,
            root_hint: None,
        };
        alpha_implicit.span.start = shuck_ast::Position::at(
            alpha_implicit.span.start.line(),
            alpha_implicit.span.start.column(),
            7,
        );

        let mut beta_explicit = PluginRequest {
            framework: PluginFramework::OhMyZsh,
            kind: PluginRequestKind::Plugin,
            name: "beta".to_owned(),
            span: Span::new(),
            explicit: true,
            root_hint: None,
        };
        beta_explicit.span.start = shuck_ast::Position::at(
            beta_explicit.span.start.line(),
            beta_explicit.span.start.column(),
            7,
        );

        let mut alpha_explicit = alpha_implicit.clone();
        alpha_explicit.explicit = true;

        let deduped = plugin_managers::dedup_plugin_requests(vec![
            alpha_implicit,
            beta_explicit.clone(),
            alpha_explicit,
        ]);

        assert_eq!(deduped.len(), 2);
        assert_eq!(deduped[0].name, "alpha");
        assert!(deduped[0].explicit);
        assert_eq!(deduped[1], beta_explicit);
    }

    #[cfg(windows)]
    #[test]
    fn source_dir_templates_render_windows_paths_with_shell_separators() {
        let candidate = render_template_candidate(
            &[
                TemplatePart::SourceDir,
                TemplatePart::Literal("/helper.bash".to_owned()),
            ],
            &[],
            Path::new(r"C:\workspace\loader.bash"),
        );

        assert_eq!(candidate.as_deref(), Some("C:/workspace/helper.bash"));
    }

    #[cfg(windows)]
    #[test]
    fn resolve_helper_paths_accepts_verbatim_candidates_with_shell_separators() {
        let temp = tempdir().unwrap();
        let loader = temp.path().join("loader.bash");
        let helper = temp.path().join("helper.bash");
        fs::write(&loader, "#!/bin/bash\n").unwrap();
        fs::write(&helper, "#!/bin/bash\n").unwrap();

        let canonical_loader = fs::canonicalize(&loader).unwrap();
        let candidate = format!(
            "{}/helper.bash",
            canonical_loader.parent().unwrap().to_string_lossy()
        );

        let context = SourceClosureLookupContext {
            source_path_resolver: None,
            plugin_resolver: None,
            file_entry_contract_collector_factory: None,
            analyzed_paths: None,
            shell_profile: ShellProfile::native(ParseShellDialect::Bash),
            resolved_helper_paths: RefCell::new(FxHashMap::default()),
            dependency_paths: RefCell::new(FxHashSet::default()),
        };
        let resolved = resolve_helper_paths(&canonical_loader, &candidate, &context);

        assert_eq!(resolved.paths.len(), 1);
        assert_eq!(
            fs::canonicalize(&resolved.paths[0]).unwrap(),
            fs::canonicalize(&helper).unwrap()
        );
    }
}
