Skip to main content

shuck_semantic/
lib.rs

1#![warn(missing_docs)]
2#![cfg_attr(not(test), warn(clippy::unwrap_used))]
3
4//! Semantic analysis for shell scripts parsed by Shuck.
5//!
6//! The semantic model tracks scopes, bindings, references, control flow, and selected dataflow
7//! facts so higher-level crates can reason about shell behavior without re-traversing the AST.
8mod analysis;
9mod array_use;
10mod binding;
11mod builder;
12mod call_graph;
13mod cfg;
14mod command_topology;
15mod contract;
16mod dataflow;
17mod declaration;
18mod dense_bit_set;
19mod editor;
20mod function_call_reachability;
21mod function_resolution;
22mod glob;
23mod nonpersistent;
24mod reachability;
25mod reference;
26mod runtime;
27mod scope;
28mod source_closure;
29mod source_ref;
30mod uninitialized;
31mod unused;
32mod value_flow;
33mod zsh_options;
34mod zsh_plugin_framework;
35
36/// Binding types and provenance metadata discovered during semantic analysis.
37pub use binding::{
38    AssignmentValueOrigin, Binding, BindingAttributes, BindingId, BindingKind, BindingOrigin,
39    BuiltinBindingTargetKind, LoopValueOrigin,
40};
41/// Call-graph structures derived from the analyzed script.
42pub use call_graph::{
43    CallGraph, CallSite, OverwrittenFunction, UnreachedFunction, UnreachedFunctionReason,
44};
45/// Control-flow graph types and flow-context annotations.
46pub use cfg::{
47    BasicBlock, BlockId, BuiltinCommandKind, CommandConditionRole, CommandId, CommandKind,
48    CompoundCommandKind, ControlFlowGraph, EdgeKind, FlowContext, StatementSequenceCommand,
49    UnreachableCauseKind,
50};
51/// Command topology and flattened command-shape query types.
52pub use command_topology::{
53    SemanticCommandContext, SemanticListCommand, SemanticListOperator, SemanticListOperatorKind,
54    SemanticListSegment, SemanticPipelineCommand, SemanticPipelineOperator,
55    SemanticPipelineOperatorKind, SemanticPipelineSegment,
56};
57/// Contract and build-option types used when constructing semantic models.
58pub use contract::{
59    ContractCertainty, FileContract, FileEntryBindingInitialization, FileEntryContractCollector,
60    FileEntryContractCollectorFactory, FunctionContract, ProvidedBinding, ProvidedBindingKind,
61    SemanticBuildOptions,
62};
63/// Dataflow results surfaced by the semantic analysis layer.
64pub use dataflow::{
65    DeadCode, ReachingDefinitions, UninitializedCertainty, UninitializedReference,
66    UnusedAssignment, UnusedReason,
67};
68/// Declaration records discovered while building the semantic model.
69pub use declaration::{Declaration, DeclarationBuiltin, DeclarationOperand};
70/// Editor-facing semantic query types.
71pub use editor::{
72    EditorCompletion, EditorCompletionKind, EditorCompletionOptions, EditorCompletions,
73    EditorDocumentSymbol, EditorFunctionCallTarget, EditorHover, EditorOccurrence,
74    EditorOccurrenceKind, EditorQuery, EditorRuntimeNameTarget, EditorSymbol, EditorSymbolKind,
75    EditorSymbolTarget, RenameSet, RenameUnavailable,
76};
77/// Direct function-call reachability query types.
78pub use function_call_reachability::{
79    DirectFunctionCallReachability, DirectFunctionCallWindow, FunctionCallCandidate,
80    FunctionCallPersistence,
81};
82/// Option-sensitive globbing and expansion behavior queries.
83pub use glob::{
84    BraceCharacterClassBehavior, FieldSplittingBehavior, FileExpansionOrderBehavior,
85    GlobDotBehavior, GlobFailureBehavior, GlobPatternBehavior, PathnameExpansionBehavior,
86    PatternOperatorBehavior,
87};
88/// Nonpersistent assignment effects, such as assignments made in subshells and read later outside.
89pub use nonpersistent::{
90    NonpersistentAssignmentAnalysis, NonpersistentAssignmentAnalysisContext,
91    NonpersistentAssignmentAnalysisOptions, NonpersistentAssignmentCommandContext,
92    NonpersistentAssignmentEffect, NonpersistentAssignmentExtraRead, NonpersistentLaterUseKind,
93};
94/// Reference types and identifiers tracked by the semantic model.
95pub use reference::{Reference, ReferenceId, ReferenceKind};
96/// Scope types and identifiers tracked by the semantic model.
97pub use scope::{FunctionScopeKind, Scope, ScopeId, ScopeKind};
98/// Shell parser option types reused by the semantic analysis layer.
99pub use shuck_parser::{OptionValue, ShellDialect, ShellProfile, ZshEmulationMode, ZshOptionState};
100/// Zsh plugin framework layout helpers.
101pub use source_closure::{layout_for_plugin_framework, zsh_plugin_frameworks};
102/// Source-reference records and resolution state.
103pub use source_ref::{SourceRef, SourceRefDiagnosticClass, SourceRefKind, SourceRefResolution};
104/// Value-flow query object built over semantic bindings, call sites, CFG, and dataflow.
105pub use value_flow::SemanticValueFlow;
106/// Zsh plugin framework traits and name aliases.
107pub use zsh_plugin_framework::{
108    ZshPluginFramework, resolve_zsh_plugin_entrypoint, resolve_zsh_plugin_source_paths,
109    zsh_plugin_framework_from_name, zsh_plugin_root_keys,
110};
111
112/// How an unindexed array reference behaves at a source offset.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum ArrayReferencePolicy {
115    /// The shell requires an explicit element or selector for array references.
116    RequiresExplicitSelector,
117    /// Native zsh treats an unindexed array reference as a scalar first-element read.
118    NativeZshScalar,
119    /// Runtime option state may select either policy.
120    Ambiguous,
121}
122
123/// How indexed array subscripts are interpreted at a source offset.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum SubscriptIndexBehavior {
126    /// Subscript `1` names the first array element.
127    OneBased,
128    /// Subscript `0` names the first array element.
129    ZeroBased,
130    /// Subscript `1` names the first element, but `0` is accepted as an alias.
131    OneBasedWithZeroAlias,
132    /// Runtime option state may select more than one indexing policy.
133    Ambiguous,
134}
135
136/// How arithmetic number literals are interpreted at a source offset.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum ArithmeticLiteralBehavior {
139    /// Decimal literals are used unless an explicit shell arithmetic base is present.
140    DecimalUnlessExplicitBase,
141    /// Leading zeroes denote octal literals.
142    LeadingZeroOctal,
143    /// Both C-style base prefixes and leading-zero octal literals are active.
144    CStyleAndLeadingZeroOctal,
145    /// Runtime option state may select more than one literal policy.
146    Ambiguous,
147}
148
149/// Option-sensitive shell behavior visible at a source offset.
150#[derive(Debug, Clone)]
151pub struct ShellBehaviorAt<'model> {
152    shell: ShellDialect,
153    zsh_options: Option<&'model ZshOptionState>,
154    runtime_options: Option<ZshOptionState>,
155    zsh_sh_emulation: Option<bool>,
156}
157
158impl ShellBehaviorAt<'_> {
159    fn effective_zsh_options(&self) -> Option<&ZshOptionState> {
160        self.runtime_options.as_ref().or(self.zsh_options)
161    }
162
163    /// Returns the effective zsh option state when this behavior is for zsh.
164    pub fn zsh_options(&self) -> Option<&ZshOptionState> {
165        self.effective_zsh_options()
166    }
167
168    /// Returns the shell dialect this behavior was computed for.
169    pub fn shell_dialect(&self) -> ShellDialect {
170        self.shell
171    }
172
173    /// Returns whether this offset is in zsh's `emulate sh`-style compatibility behavior.
174    pub fn zsh_sh_emulation(&self) -> bool {
175        self.shell == ShellDialect::Zsh && self.zsh_sh_emulation.unwrap_or(false)
176    }
177
178    /// Creates behavior for a shell dialect without source-local option state.
179    pub fn for_dialect(shell: ShellDialect) -> Self {
180        Self {
181            shell,
182            zsh_options: None,
183            runtime_options: None,
184            zsh_sh_emulation: None,
185        }
186    }
187
188    /// Returns this behavior with a caller-provided zsh option overlay applied.
189    ///
190    /// Non-zsh behavior is returned unchanged. For zsh, the overlay starts with the
191    /// effective source-local option state, or the native zsh defaults when no
192    /// source-local state is available.
193    pub fn with_zsh_option_overlay(mut self, overlay: impl FnOnce(&mut ZshOptionState)) -> Self {
194        if self.shell == ShellDialect::Zsh {
195            let mut options = self
196                .effective_zsh_options()
197                .cloned()
198                .unwrap_or_else(ZshOptionState::zsh_default);
199            overlay(&mut options);
200            self.runtime_options = Some(options);
201        }
202        self
203    }
204
205    /// Returns the array-reference policy implied by the shell and runtime option state.
206    pub fn array_reference_policy(&self) -> ArrayReferencePolicy {
207        if self.shell != ShellDialect::Zsh {
208            return ArrayReferencePolicy::RequiresExplicitSelector;
209        }
210
211        match self
212            .effective_zsh_options()
213            .map(|options| options.ksh_arrays)
214        {
215            Some(OptionValue::Off) => ArrayReferencePolicy::NativeZshScalar,
216            Some(OptionValue::Unknown) => ArrayReferencePolicy::Ambiguous,
217            Some(OptionValue::On) | None => ArrayReferencePolicy::RequiresExplicitSelector,
218        }
219    }
220
221    /// Returns how array subscript indexes are interpreted.
222    pub fn subscript_indexing(&self) -> SubscriptIndexBehavior {
223        if self.shell != ShellDialect::Zsh {
224            return SubscriptIndexBehavior::ZeroBased;
225        }
226
227        let Some(options) = self.effective_zsh_options() else {
228            return SubscriptIndexBehavior::OneBased;
229        };
230
231        match (options.ksh_arrays, options.ksh_zero_subscript) {
232            (OptionValue::On, _) => SubscriptIndexBehavior::ZeroBased,
233            (OptionValue::Unknown, _) | (OptionValue::Off, OptionValue::Unknown) => {
234                SubscriptIndexBehavior::Ambiguous
235            }
236            (OptionValue::Off, OptionValue::On) => SubscriptIndexBehavior::OneBasedWithZeroAlias,
237            (OptionValue::Off, OptionValue::Off) => SubscriptIndexBehavior::OneBased,
238        }
239    }
240
241    /// Returns how arithmetic numeric literals are interpreted.
242    pub fn arithmetic_literals(&self) -> ArithmeticLiteralBehavior {
243        if self.shell != ShellDialect::Zsh {
244            return ArithmeticLiteralBehavior::CStyleAndLeadingZeroOctal;
245        }
246
247        let Some(options) = self.effective_zsh_options() else {
248            return ArithmeticLiteralBehavior::DecimalUnlessExplicitBase;
249        };
250
251        match options.octal_zeroes {
252            OptionValue::Off => ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
253            OptionValue::On => ArithmeticLiteralBehavior::LeadingZeroOctal,
254            OptionValue::Unknown => ArithmeticLiteralBehavior::Ambiguous,
255        }
256    }
257}
258
259/// A function scope reached through a top-level `case "$1"` style CLI dispatcher.
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub struct CaseCliDispatch {
262    function_scope: ScopeId,
263    dispatcher_span: Span,
264}
265
266impl CaseCliDispatch {
267    fn new(function_scope: ScopeId, dispatcher_span: Span) -> Self {
268        Self {
269            function_scope,
270            dispatcher_span,
271        }
272    }
273
274    /// The function body scope selected by the dispatcher.
275    pub fn function_scope(self) -> ScopeId {
276        self.function_scope
277    }
278
279    /// The span of the dynamic positional command used for dispatch.
280    pub fn dispatcher_span(self) -> Span {
281        self.dispatcher_span
282    }
283}
284
285use rustc_hash::{FxHashMap, FxHashSet};
286use shuck_ast::{Command, ConditionalExpr, File, Name, Span, Stmt};
287use shuck_indexer::Indexer;
288use smallvec::{Array, SmallVec};
289use std::path::{Path, PathBuf};
290use std::sync::OnceLock;
291
292use crate::builder::SemanticModelBuilder;
293use crate::call_graph::build_call_graph;
294use crate::cfg::RecordedProgram;
295use crate::command_topology::CommandTopology;
296#[cfg(test)]
297use crate::dataflow::DataflowResult;
298use crate::dataflow::{DataflowContext, ExactVariableDataflow};
299use crate::function_resolution::FunctionBindingLookup;
300use crate::runtime::RuntimePrelude;
301use crate::scope::{ancestor_scopes, enclosing_function_scope};
302use crate::zsh_options::ZshOptionAnalysis;
303
304const MAX_FUNCTIONS_FOR_TERMINATION_REACHABILITY: usize = 200;
305const MAX_TERMINATION_REACHABILITY_WORK: usize = 20_000;
306
307struct AssocCallerSeenNames {
308    inline: SmallVec<[Name; 8]>,
309    hashed: Option<FxHashSet<Name>>,
310}
311
312impl AssocCallerSeenNames {
313    const HASH_THRESHOLD: usize = 32;
314
315    fn new() -> Self {
316        Self {
317            inline: SmallVec::new(),
318            hashed: None,
319        }
320    }
321
322    fn insert(&mut self, name: &Name) -> bool {
323        if let Some(hashed) = &mut self.hashed {
324            return hashed.insert(name.clone());
325        }
326
327        if self.inline.iter().any(|seen_name| seen_name == name) {
328            return false;
329        }
330
331        if self.inline.len() < Self::HASH_THRESHOLD {
332            self.inline.push(name.clone());
333            return true;
334        }
335
336        let mut hashed =
337            FxHashSet::with_capacity_and_hasher(Self::HASH_THRESHOLD * 2, Default::default());
338        hashed.extend(self.inline.drain(..));
339        let inserted = hashed.insert(name.clone());
340        self.hashed = Some(hashed);
341        inserted
342    }
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
346pub(crate) struct SpanKey {
347    start: usize,
348    end: usize,
349}
350
351impl SpanKey {
352    pub(crate) fn new(span: Span) -> Self {
353        Self {
354            start: span.start.offset,
355            end: span.end.offset,
356        }
357    }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
361pub(crate) struct SourceDirectiveOverride {
362    pub(crate) kind: SourceRefKind,
363    pub(crate) own_line: bool,
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub(crate) enum IndirectTargetHint {
368    Exact {
369        name: Name,
370        array_like: bool,
371    },
372    Pattern {
373        prefix: compact_str::CompactString,
374        suffix: compact_str::CompactString,
375        array_like: bool,
376    },
377}
378
379/// Synthetic read introduced by semantic modeling for later analysis passes.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct SyntheticRead {
382    pub(crate) scope: ScopeId,
383    pub(crate) span: Span,
384    pub(crate) name: Name,
385}
386
387/// Summary of positional-parameter reads reachable from a function scope.
388#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
389pub struct FunctionPositionalReferenceSummary {
390    required_arg_count: usize,
391    uses_unprotected_positional_parameters: bool,
392}
393
394impl FunctionPositionalReferenceSummary {
395    /// Returns the highest positional argument index that is definitely required.
396    pub fn required_arg_count(self) -> usize {
397        self.required_arg_count
398    }
399
400    /// Returns whether the function reads positional parameters without a guarding default.
401    pub fn uses_unprotected_positional_parameters(self) -> bool {
402        self.uses_unprotected_positional_parameters
403    }
404
405    fn record_required_arg_count(&mut self, index: usize) {
406        self.required_arg_count = self.required_arg_count.max(index);
407        self.uses_unprotected_positional_parameters = true;
408    }
409
410    fn record_use(&mut self) {
411        self.uses_unprotected_positional_parameters = true;
412    }
413}
414
415/// Behavior flags for unused-assignment analysis.
416#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
417pub struct UnusedAssignmentAnalysisOptions {
418    /// Whether a resolved scalar indirect-expansion target like `${!name}` counts as a use
419    /// of the target. ShellCheck-compatible analysis leaves this disabled. Array-like
420    /// targets such as `name=arr[@]; ${!name}` stay live in both modes.
421    pub treat_indirect_expansion_targets_as_used: bool,
422    /// Whether assignments in statically unreachable blocks should still be eligible
423    /// for unused-assignment reporting.
424    pub report_unreachable_assignments: bool,
425}
426
427/// Behavior flags for unreached-function analysis.
428#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
429pub struct UnreachedFunctionAnalysisOptions {
430    /// Whether nested function definitions should be reported when their enclosing function scope
431    /// is not reached by a direct call chain.
432    pub report_unreached_nested_definitions: bool,
433}
434
435impl SyntheticRead {
436    /// Returns the scope where the synthetic read should be considered visible.
437    pub fn scope(&self) -> ScopeId {
438        self.scope
439    }
440
441    /// Returns the span that higher layers should attribute to the synthetic read.
442    pub fn span(&self) -> Span {
443        self.span
444    }
445
446    /// Returns the runtime name read by this synthetic entry.
447    pub fn name(&self) -> &Name {
448        &self.name
449    }
450}
451
452#[doc(hidden)]
453pub trait TraversalObserver<'a> {
454    fn enter_command(&mut self, _command: &Command, _scope: ScopeId, _flow: FlowContext) {}
455
456    fn exit_command(&mut self, _command: &Command, _scope: ScopeId) {}
457
458    fn conditional_expression(
459        &mut self,
460        _command_span: Span,
461        _expression: &'a ConditionalExpr,
462        _parent_in_same_logical_group: bool,
463    ) {
464    }
465
466    fn recorded_command(
467        &mut self,
468        _id: CommandId,
469        _stmt: &'a Stmt,
470        _scope: ScopeId,
471        _flow: FlowContext,
472    ) {
473    }
474
475    fn recorded_statement_sequence_command(
476        &mut self,
477        _body_span: Span,
478        _stmt_span: Span,
479        _id: CommandId,
480    ) {
481    }
482
483    fn record_binding(&mut self, _binding: &Binding) {}
484
485    fn record_reference(&mut self, _reference: &Reference, _resolved: Option<&Binding>) {}
486}
487
488#[doc(hidden)]
489pub struct NoopTraversalObserver;
490
491impl<'a> TraversalObserver<'a> for NoopTraversalObserver {}
492
493#[doc(hidden)]
494pub trait SourcePathResolver {
495    fn resolve_candidate_paths(&self, source_path: &Path, candidate: &str) -> Vec<PathBuf>;
496}
497
498impl<F> SourcePathResolver for F
499where
500    F: Fn(&Path, &str) -> Vec<PathBuf> + Send + Sync,
501{
502    fn resolve_candidate_paths(&self, source_path: &Path, candidate: &str) -> Vec<PathBuf> {
503        self(source_path, candidate)
504    }
505}
506
507/// Known plugin frameworks that can resolve logical plugin names into entrypoint files.
508#[derive(Debug, Clone, PartialEq, Eq, Hash)]
509pub enum PluginFramework {
510    /// oh-my-zsh style plugin and theme layouts.
511    OhMyZsh,
512    /// Prezto module layouts.
513    Prezto,
514    /// zdot module layouts.
515    Zdot,
516    /// Zinit/Zi plugin manager layouts.
517    Zinit,
518    /// An explicit filesystem path rather than a logical framework/plugin pair.
519    ExplicitFilesystem,
520    /// A custom framework name reserved for future extension points.
521    Other(String),
522}
523
524/// The kind of plugin request being resolved.
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
526pub enum PluginRequestKind {
527    /// A logical framework plugin such as `git`.
528    Plugin,
529    /// A logical framework theme such as `agnoster`.
530    Theme,
531    /// An explicit raw entrypoint path.
532    Entrypoint,
533}
534
535/// A single plugin load request extracted from source or configuration.
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct PluginRequest {
538    /// Framework used to resolve this request.
539    pub framework: PluginFramework,
540    /// Request category.
541    pub kind: PluginRequestKind,
542    /// Plugin name, theme name, or raw entrypoint path depending on `kind`.
543    pub name: String,
544    /// Source span used to anchor imported bindings and reads.
545    pub span: Span,
546    /// Whether the request came from explicit user configuration.
547    pub explicit: bool,
548    /// Concrete framework root inferred from the source file when one is available.
549    pub root_hint: Option<PathBuf>,
550}
551
552/// Resolved plugin entrypoints and optional future plugin contracts.
553#[derive(Debug, Clone, Default, PartialEq, Eq)]
554pub struct PluginResolution {
555    /// Plugin entrypoint files to summarize and import.
556    pub entrypoints: Vec<PathBuf>,
557    /// Optional plugin contracts supplied without reading entrypoint files.
558    pub file_entry_contracts: Vec<FileContract>,
559    /// Optional file-scoped caller contract applied to the file that requested the plugin.
560    pub requesting_file_contract: FileContract,
561}
562
563/// Resolver for zsh plugin loads discovered during semantic analysis.
564pub trait PluginResolver {
565    /// Returns additional configured plugin requests for `source_path`.
566    fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
567        Vec::new()
568    }
569
570    /// Resolves a zsh `source` candidate through plugin/framework roots.
571    fn resolve_source_path(&self, _source_path: &Path, _candidate: &str) -> Vec<PathBuf> {
572        Vec::new()
573    }
574
575    /// Resolves one plugin request into entrypoint paths and optional contracts.
576    fn resolve_plugin_request(
577        &self,
578        source_path: &Path,
579        request: &PluginRequest,
580    ) -> PluginResolution;
581}
582
583fn dedup_synthetic_reads(reads: Vec<SyntheticRead>) -> Vec<SyntheticRead> {
584    let mut seen = FxHashSet::default();
585    let mut deduped = Vec::new();
586    for read in reads {
587        if seen.insert((read.scope, read.span.start.offset, read.name.clone())) {
588            deduped.push(read);
589        }
590    }
591    deduped
592}
593
594fn assignment_like_binding(kind: BindingKind) -> bool {
595    matches!(
596        kind,
597        BindingKind::Assignment
598            | BindingKind::AppendAssignment
599            | BindingKind::ArrayAssignment
600            | BindingKind::ArithmeticAssignment
601    )
602}
603
604fn binding_blocks_same_scope_assoc_lookup(binding: &Binding) -> bool {
605    binding.attributes.contains(BindingAttributes::LOCAL) || !assignment_like_binding(binding.kind)
606}
607
608fn previous_visible_binding_id_from_slice(
609    all_bindings: &[Binding],
610    bindings: &[BindingId],
611    offset: usize,
612    ignored_binding_span: Option<Span>,
613) -> Option<BindingId> {
614    let candidate_count = bindings
615        .partition_point(|binding_id| all_bindings[binding_id.index()].span.start.offset <= offset);
616
617    bindings[..candidate_count]
618        .iter()
619        .rev()
620        .copied()
621        .find(|binding_id| ignored_binding_span != Some(all_bindings[binding_id.index()].span))
622}
623
624trait BindingIdCollection {
625    fn as_slice(&self) -> &[BindingId];
626    fn insert_binding_id(&mut self, index: usize, id: BindingId);
627}
628
629impl BindingIdCollection for Vec<BindingId> {
630    fn as_slice(&self) -> &[BindingId] {
631        self
632    }
633
634    fn insert_binding_id(&mut self, index: usize, id: BindingId) {
635        self.insert(index, id);
636    }
637}
638
639impl<A> BindingIdCollection for SmallVec<A>
640where
641    A: Array<Item = BindingId>,
642{
643    fn as_slice(&self) -> &[BindingId] {
644        self
645    }
646
647    fn insert_binding_id(&mut self, index: usize, id: BindingId) {
648        self.insert(index, id);
649    }
650}
651
652fn insert_binding_id_sorted(
653    bindings: &mut impl BindingIdCollection,
654    all_bindings: &[Binding],
655    id: BindingId,
656) {
657    let target = &all_bindings[id.index()];
658    let insertion = bindings.as_slice().partition_point(|candidate_id| {
659        let candidate = &all_bindings[candidate_id.index()];
660        candidate.span.start.offset < target.span.start.offset
661            || (candidate.span.start.offset == target.span.start.offset
662                && candidate.span.end.offset < target.span.end.offset)
663            || (candidate.span.start.offset == target.span.start.offset
664                && candidate.span.end.offset == target.span.end.offset
665                && candidate.id.index() < target.id.index())
666    });
667    bindings.insert_binding_id(insertion, id);
668}
669
670#[derive(Debug)]
671struct AssocLookupBindingIndex {
672    blocking_bindings_by_scope: Vec<FxHashMap<Name, Box<[BindingId]>>>,
673}
674
675#[derive(Debug)]
676struct ScopeProvidedBindingIndex {
677    provided_bindings_by_scope: Vec<Box<[ProvidedBinding]>>,
678    definite_provider_scopes_by_name: FxHashMap<Name, Box<[ScopeId]>>,
679}
680
681#[derive(Debug)]
682struct ScopeLookup {
683    children: Vec<Box<[ScopeId]>>,
684}
685
686impl ScopeLookup {
687    fn new(scopes: &[Scope]) -> Self {
688        let mut children = vec![Vec::new(); scopes.len()];
689
690        for scope in scopes {
691            if let Some(parent) = scope.parent {
692                children[parent.index()].push(scope.id);
693            }
694        }
695
696        for scope_ids in &mut children {
697            scope_ids.sort_by_key(|scope_id| {
698                let span = scopes[scope_id.index()].span;
699                (span.start.offset, span.end.offset)
700            });
701        }
702
703        Self {
704            children: children.into_iter().map(Vec::into_boxed_slice).collect(),
705        }
706    }
707
708    fn scope_at(&self, scopes: &[Scope], offset: usize) -> Option<ScopeId> {
709        let root = scopes.first()?;
710        if !contains_offset(root.span, offset) {
711            return None;
712        }
713
714        let mut scope = root.id;
715        while let Some(child) = self.child_scope_at(scopes, scope, offset) {
716            scope = child;
717        }
718
719        Some(scope)
720    }
721
722    fn child_scope_at(&self, scopes: &[Scope], parent: ScopeId, offset: usize) -> Option<ScopeId> {
723        let children = self.children.get(parent.index())?;
724        let cutoff = children
725            .partition_point(|scope_id| scopes[scope_id.index()].span.start.offset <= offset);
726        let mut best: Option<ScopeId> = None;
727        let mut index = cutoff;
728
729        while index > 0 {
730            index -= 1;
731            let scope_id = children[index];
732            let span = scopes[scope_id.index()].span;
733            if span.end.offset < offset {
734                break;
735            }
736            if contains_offset(span, offset) {
737                match best {
738                    Some(current)
739                        if scope_span_width(scopes[current.index()].span)
740                            <= scope_span_width(span) => {}
741                    _ => best = Some(scope_id),
742                }
743            }
744        }
745
746        best
747    }
748}
749
750/// Semantic model constructed from a parsed shell file and source text.
751#[derive(Debug)]
752pub struct SemanticModel {
753    shell_profile: ShellProfile,
754    scopes: Vec<Scope>,
755    scope_lookup: ScopeLookup,
756    bindings: Vec<Binding>,
757    references: Vec<Reference>,
758    reference_index: FxHashMap<Name, SmallVec<[ReferenceId; 2]>>,
759    predefined_runtime_refs: FxHashSet<ReferenceId>,
760    guarded_parameter_refs: FxHashSet<ReferenceId>,
761    parameter_guard_flow_refs: FxHashSet<ReferenceId>,
762    defaulting_parameter_operand_refs: FxHashSet<ReferenceId>,
763    self_referential_assignment_refs: FxHashSet<ReferenceId>,
764    binding_index: FxHashMap<Name, SmallVec<[BindingId; 2]>>,
765    resolved: FxHashMap<ReferenceId, BindingId>,
766    unresolved: Vec<ReferenceId>,
767    functions: FxHashMap<Name, SmallVec<[BindingId; 2]>>,
768    call_sites: FxHashMap<Name, SmallVec<[CallSite; 2]>>,
769    call_graph: OnceLock<CallGraph>,
770    source_refs: Vec<SourceRef>,
771    source_path_templates_by_binding: FxHashMap<BindingId, source_closure::SourcePathTemplate>,
772    runtime: RuntimePrelude,
773    declarations: Vec<Declaration>,
774    indirect_target_hints: FxHashMap<BindingId, IndirectTargetHint>,
775    indirect_targets_by_binding: FxHashMap<BindingId, Vec<BindingId>>,
776    indirect_targets_by_reference: FxHashMap<ReferenceId, Vec<BindingId>>,
777    array_like_indirect_expansion_refs: FxHashSet<ReferenceId>,
778    synthetic_reads: Vec<SyntheticRead>,
779    entry_bindings: Vec<BindingId>,
780    flow_contexts: Vec<(Span, FlowContext)>,
781    recorded_program: RecordedProgram,
782    command_bindings: FxHashMap<SpanKey, SmallVec<[BindingId; 2]>>,
783    command_references: FxHashMap<SpanKey, SmallVec<[ReferenceId; 4]>>,
784    cleared_variables: FxHashMap<(ScopeId, Name), SmallVec<[usize; 2]>>,
785    import_origins_by_binding: FxHashMap<BindingId, Vec<PathBuf>>,
786    imported_dependency_paths: Vec<PathBuf>,
787    heuristic_unused_assignments: Vec<BindingId>,
788    zsh_option_analysis: OnceLock<Option<ZshOptionAnalysis>>,
789    zsh_runtime_ambiguous_entry_mask: OnceLock<zsh_options::ZshOptionMask>,
790    zsh_runtime_by_function: OnceLock<FxHashMap<ScopeId, OnceLock<Option<ZshOptionAnalysis>>>>,
791    zsh_runtime_function_summaries: OnceLock<zsh_options::SharedFunctionSummaryCache>,
792    assoc_lookup_binding_index: OnceLock<AssocLookupBindingIndex>,
793    command_topology: OnceLock<CommandTopology>,
794    references_sorted_by_start: OnceLock<Vec<ReferenceId>>,
795    bindings_sorted_by_start: OnceLock<Vec<BindingId>>,
796    bindings_by_definition_span: OnceLock<FxHashMap<SpanKey, BindingId>>,
797    guarded_or_defaulting_reference_offsets_by_name: OnceLock<FxHashMap<Name, Box<[usize]>>>,
798    declarations_by_command_span: OnceLock<FxHashMap<SpanKey, usize>>,
799    editor_document_symbol_ranges_by_binding: OnceLock<Vec<Option<Span>>>,
800    editor_hover_targets: OnceLock<editor::EditorHoverTargets>,
801    unconditional_function_bindings: OnceLock<FxHashSet<BindingId>>,
802    function_bindings_by_scope: OnceLock<FxHashMap<ScopeId, SmallVec<[BindingId; 2]>>>,
803    visible_function_call_bindings: OnceLock<FxHashMap<SpanKey, BindingId>>,
804    function_definition_binding_ids: OnceLock<Vec<BindingId>>,
805    array_use_index: OnceLock<array_use::ArrayUseIndex>,
806}
807
808/// Lazy analysis view over a `SemanticModel`.
809#[derive(Debug)]
810pub struct SemanticAnalysis<'model> {
811    model: &'model SemanticModel,
812    cfg: OnceLock<ControlFlowGraph>,
813    exact_variable_dataflow: OnceLock<ExactVariableDataflow>,
814    #[cfg(test)]
815    dataflow: OnceLock<DataflowResult>,
816    unused_assignments: OnceLock<Vec<BindingId>>,
817    unused_assignments_shellcheck_compat: OnceLock<Vec<BindingId>>,
818    uninitialized_references: OnceLock<Vec<UninitializedReference>>,
819    uninitialized_reference_certainties: OnceLock<FxHashMap<SpanKey, UninitializedCertainty>>,
820    dead_code: OnceLock<Vec<DeadCode>>,
821    unreachable_blocks: OnceLock<FxHashSet<BlockId>>,
822    binding_block_index: OnceLock<Vec<Vec<BlockId>>>,
823    overwritten_functions: OnceLock<Vec<OverwrittenFunction>>,
824    unreached_functions: OnceLock<Vec<UnreachedFunction>>,
825    unreached_functions_shellcheck_compat: OnceLock<Vec<UnreachedFunction>>,
826    scope_provided_binding_index: OnceLock<ScopeProvidedBindingIndex>,
827}
828
829impl SemanticModel {
830    /// Builds a semantic model for `file` using the default build options.
831    pub fn build(file: &File, source: &str, indexer: &Indexer) -> Self {
832        Self::build_with_options(file, source, indexer, SemanticBuildOptions::default())
833    }
834
835    /// Builds a semantic model for `file` using explicit semantic build options.
836    pub fn build_with_options(
837        file: &File,
838        source: &str,
839        indexer: &Indexer,
840        options: SemanticBuildOptions<'_>,
841    ) -> Self {
842        let mut observer = NoopTraversalObserver;
843        build_with_observer_with_options(file, source, indexer, &mut observer, options)
844    }
845
846    fn from_build_output(built: builder::BuildOutput) -> Self {
847        let mut reference_index = built.reference_index;
848        for reference_ids in reference_index.values_mut() {
849            reference_ids.sort_by_key(|reference_id| {
850                built.references[reference_id.index()].span.start.offset
851            });
852        }
853        let indirect_targets_by_binding =
854            build_indirect_targets_by_binding(&built.bindings, &built.indirect_target_hints);
855        let indirect_targets_by_reference = build_indirect_targets_by_reference(
856            &built.references,
857            &built.resolved,
858            &built.indirect_expansion_refs,
859            &indirect_targets_by_binding,
860        );
861        let array_like_indirect_expansion_refs = build_array_like_indirect_expansion_refs(
862            &built.references,
863            &built.resolved,
864            &built.indirect_expansion_refs,
865            &built.indirect_target_hints,
866        );
867        let scope_lookup = ScopeLookup::new(&built.scopes);
868        Self {
869            shell_profile: built.shell_profile,
870            scopes: built.scopes,
871            scope_lookup,
872            bindings: built.bindings,
873            references: built.references,
874            reference_index,
875            predefined_runtime_refs: built.predefined_runtime_refs,
876            guarded_parameter_refs: built.guarded_parameter_refs,
877            parameter_guard_flow_refs: built.parameter_guard_flow_refs,
878            defaulting_parameter_operand_refs: built.defaulting_parameter_operand_refs,
879            self_referential_assignment_refs: built.self_referential_assignment_refs,
880            binding_index: built.binding_index,
881            resolved: built.resolved,
882            unresolved: built.unresolved,
883            functions: built.functions,
884            call_sites: built.call_sites,
885            call_graph: OnceLock::new(),
886            source_refs: built.source_refs,
887            source_path_templates_by_binding: built.source_path_templates_by_binding,
888            runtime: built.runtime,
889            declarations: built.declarations,
890            indirect_target_hints: built.indirect_target_hints,
891            indirect_targets_by_binding,
892            indirect_targets_by_reference,
893            array_like_indirect_expansion_refs,
894            synthetic_reads: Vec::new(),
895            entry_bindings: Vec::new(),
896            flow_contexts: built.flow_contexts,
897            recorded_program: built.recorded_program,
898            command_bindings: built.command_bindings,
899            command_references: built.command_references,
900            cleared_variables: built.cleared_variables,
901            import_origins_by_binding: FxHashMap::default(),
902            imported_dependency_paths: Vec::new(),
903            heuristic_unused_assignments: built.heuristic_unused_assignments,
904            zsh_option_analysis: OnceLock::new(),
905            zsh_runtime_ambiguous_entry_mask: OnceLock::new(),
906            zsh_runtime_by_function: OnceLock::new(),
907            zsh_runtime_function_summaries: OnceLock::new(),
908            assoc_lookup_binding_index: OnceLock::new(),
909            command_topology: OnceLock::new(),
910            references_sorted_by_start: OnceLock::new(),
911            bindings_sorted_by_start: OnceLock::new(),
912            bindings_by_definition_span: OnceLock::new(),
913            guarded_or_defaulting_reference_offsets_by_name: OnceLock::new(),
914            declarations_by_command_span: OnceLock::new(),
915            editor_document_symbol_ranges_by_binding: OnceLock::new(),
916            editor_hover_targets: OnceLock::new(),
917            unconditional_function_bindings: OnceLock::new(),
918            function_bindings_by_scope: OnceLock::new(),
919            visible_function_call_bindings: OnceLock::new(),
920            function_definition_binding_ids: OnceLock::new(),
921            array_use_index: OnceLock::new(),
922        }
923    }
924
925    /// Returns a lazy analysis view that computes CFG, dataflow, and reachability on demand.
926    pub fn analysis(&self) -> SemanticAnalysis<'_> {
927        SemanticAnalysis::new(self)
928    }
929
930    /// Returns the shell profile used when building this model.
931    pub fn shell_profile(&self) -> &ShellProfile {
932        &self.shell_profile
933    }
934
935    /// Returns the zsh option state visible at `offset`, when the model tracks zsh options.
936    pub fn zsh_options_at(&self, offset: usize) -> Option<&ZshOptionState> {
937        self.zsh_option_analysis()
938            .and_then(|analysis| analysis.options_at(&self.scopes, offset))
939    }
940
941    /// Returns whether `offset` is in a definite zsh `emulate sh` region.
942    pub fn zsh_sh_emulation_at(&self, offset: usize) -> Option<bool> {
943        self.zsh_option_analysis()
944            .and_then(|analysis| analysis.sh_emulation_at(&self.scopes, offset))
945    }
946
947    /// Returns option-sensitive shell behavior visible at `offset`.
948    pub fn shell_behavior_at(&self, offset: usize) -> ShellBehaviorAt<'_> {
949        ShellBehaviorAt {
950            shell: self.shell_profile.dialect,
951            zsh_options: self.zsh_options_at(offset),
952            runtime_options: self.zsh_runtime_options_at(offset),
953            zsh_sh_emulation: self.zsh_sh_emulation_at(offset),
954        }
955    }
956
957    fn zsh_runtime_ambiguous_entry_mask(&self) -> zsh_options::ZshOptionMask {
958        if self.shell_profile.zsh_options().is_none() {
959            return zsh_options::ZshOptionMask::default();
960        }
961
962        *self.zsh_runtime_ambiguous_entry_mask.get_or_init(|| {
963            crate::zsh_options::runtime_ambiguous_entry_mask(&self.recorded_program)
964        })
965    }
966
967    fn zsh_runtime_options_at(&self, offset: usize) -> Option<ZshOptionState> {
968        self.shell_profile.zsh_options()?;
969        let ordinary = *self.zsh_options_at(offset)?;
970        let ambiguous_entry = self.zsh_runtime_ambiguous_entry_mask();
971        if ambiguous_entry.is_empty() {
972            return None;
973        }
974
975        let scope = self.scope_at(offset);
976        let function_scope = self.enclosing_function_scope(scope)?;
977        let ambient = self
978            .zsh_runtime_analysis_for_function(function_scope)
979            .and_then(|analysis| analysis.options_at(&self.scopes, offset));
980
981        Some(ambient.map_or(ordinary, |options| ordinary.merge(options)))
982    }
983
984    fn zsh_option_analysis(&self) -> Option<&ZshOptionAnalysis> {
985        self.zsh_option_analysis
986            .get_or_init(|| self.build_zsh_option_analysis())
987            .as_ref()
988    }
989
990    fn build_zsh_option_analysis(&self) -> Option<ZshOptionAnalysis> {
991        let zsh_dynamic_calls = zsh_options::DynamicCallAnalysisContext {
992            references: &self.references,
993            resolved: &self.resolved,
994            indirect_target_hints: &self.indirect_target_hints,
995            indirect_targets_by_binding: &self.indirect_targets_by_binding,
996            command_references: &self.command_references,
997        };
998        zsh_options::analyze(
999            &self.shell_profile,
1000            &self.scopes,
1001            &self.bindings,
1002            &self.recorded_program,
1003            zsh_dynamic_calls,
1004        )
1005    }
1006
1007    fn zsh_runtime_by_function(&self) -> &FxHashMap<ScopeId, OnceLock<Option<ZshOptionAnalysis>>> {
1008        self.zsh_runtime_by_function.get_or_init(|| {
1009            self.recorded_program
1010                .function_bodies()
1011                .keys()
1012                .map(|&function_scope| (function_scope, OnceLock::new()))
1013                .collect()
1014        })
1015    }
1016
1017    fn zsh_runtime_analysis_for_function(
1018        &self,
1019        function_scope: ScopeId,
1020    ) -> Option<&ZshOptionAnalysis> {
1021        self.zsh_runtime_by_function()
1022            .get(&function_scope)?
1023            .get_or_init(|| self.build_zsh_runtime_analysis_for_function(function_scope))
1024            .as_ref()
1025    }
1026
1027    fn build_zsh_runtime_analysis_for_function(
1028        &self,
1029        function_scope: ScopeId,
1030    ) -> Option<ZshOptionAnalysis> {
1031        let function_entry_offset = self.scope(function_scope).span.start.offset;
1032        let mut function_entry = *self.zsh_options_at(function_entry_offset)?;
1033        for field in self.zsh_runtime_ambiguous_entry_mask().iter() {
1034            crate::zsh_options::set_public_option_field(
1035                &mut function_entry,
1036                field,
1037                OptionValue::Unknown,
1038            );
1039        }
1040        crate::zsh_options::function_runtime_analysis_with_entry(
1041            &self.scopes,
1042            &self.bindings,
1043            &self.recorded_program,
1044            crate::zsh_options::DynamicCallAnalysisContext {
1045                references: &self.references,
1046                resolved: &self.resolved,
1047                indirect_target_hints: &self.indirect_target_hints,
1048                indirect_targets_by_binding: &self.indirect_targets_by_binding,
1049                command_references: &self.command_references,
1050            },
1051            Some(
1052                self.zsh_runtime_function_summaries
1053                    .get_or_init(Default::default),
1054            ),
1055            function_scope,
1056            function_entry,
1057        )
1058    }
1059
1060    /// Returns all semantic scopes discovered in the file.
1061    pub fn scopes(&self) -> &[Scope] {
1062        &self.scopes
1063    }
1064
1065    /// Returns the scope identified by `id`.
1066    pub fn scope(&self, id: ScopeId) -> &Scope {
1067        &self.scopes[id.index()]
1068    }
1069
1070    /// Returns all semantic bindings discovered in the file.
1071    pub fn bindings(&self) -> &[Binding] {
1072        &self.bindings
1073    }
1074
1075    /// Yield every binding with `BindingKind::FunctionDefinition`.
1076    ///
1077    /// Backed by a lazily-built index so repeat calls avoid rescanning the
1078    /// full `bindings()` slice.
1079    pub fn function_definition_bindings(&self) -> impl ExactSizeIterator<Item = &Binding> + '_ {
1080        let ids = self.function_definition_binding_ids.get_or_init(|| {
1081            self.bindings
1082                .iter()
1083                .filter(|binding| matches!(binding.kind, BindingKind::FunctionDefinition))
1084                .map(|binding| binding.id)
1085                .collect()
1086        });
1087        ids.iter().map(|id| &self.bindings[id.index()])
1088    }
1089
1090    /// Returns all semantic references discovered in the file.
1091    pub fn references(&self) -> &[Reference] {
1092        &self.references
1093    }
1094
1095    /// Returns guarded or defaulting reference offsets grouped by variable name.
1096    ///
1097    /// Backed by a lazily-built summary so repeated undefined-variable suppression
1098    /// checks can reuse the same grouped offsets instead of rescanning `references()`.
1099    pub fn guarded_or_defaulting_reference_offsets_by_name(
1100        &self,
1101    ) -> &FxHashMap<Name, Box<[usize]>> {
1102        self.guarded_or_defaulting_reference_offsets_by_name
1103            .get_or_init(|| {
1104                build_guarded_or_defaulting_reference_offsets_by_name(
1105                    &self.references,
1106                    &self.guarded_parameter_refs,
1107                    &self.defaulting_parameter_operand_refs,
1108                )
1109            })
1110    }
1111
1112    /// Summarize unguarded positional-parameter reads by enclosing function scope.
1113    ///
1114    /// Callers can pass transient-scope `set --` offsets to exclude reads that are
1115    /// masked by a nested local positional reset.
1116    pub fn function_positional_reference_summary(
1117        &self,
1118        local_reset_offsets_by_scope: &FxHashMap<ScopeId, Vec<usize>>,
1119    ) -> FxHashMap<ScopeId, FunctionPositionalReferenceSummary> {
1120        let mut summaries = FxHashMap::<ScopeId, FunctionPositionalReferenceSummary>::default();
1121
1122        for (name, reference_ids) in &self.reference_index {
1123            let Some(kind) = positional_parameter_reference_kind(name.as_str()) else {
1124                continue;
1125            };
1126
1127            for reference_id in reference_ids {
1128                let reference = &self.references[reference_id.index()];
1129                if self.is_guarded_parameter_reference(reference.id)
1130                    || reference_has_local_positional_reset(
1131                        self,
1132                        reference.scope,
1133                        reference.span.start.offset,
1134                        local_reset_offsets_by_scope,
1135                    )
1136                {
1137                    continue;
1138                }
1139
1140                let Some(function_scope) = self.enclosing_function_scope(reference.scope) else {
1141                    continue;
1142                };
1143
1144                let entry = summaries.entry(function_scope).or_default();
1145                match kind {
1146                    PositionalParameterReferenceKind::Indexed(index) => {
1147                        entry.record_required_arg_count(index);
1148                    }
1149                    PositionalParameterReferenceKind::Special => entry.record_use(),
1150                }
1151            }
1152        }
1153
1154        summaries
1155    }
1156
1157    /// Yield every reference whose span is fully contained within `outer`.
1158    ///
1159    /// Backed by a lazily-built index sorted by reference start offset, so a
1160    /// per-span query costs `O(log n + matches)` rather than scanning every
1161    /// reference in the file.
1162    pub fn references_in_span(&self, outer: Span) -> ReferencesInSpan<'_> {
1163        let sorted = self
1164            .references_sorted_by_start
1165            .get_or_init(|| build_references_sorted_by_start(&self.references));
1166        let lower = sorted.partition_point(|id| {
1167            self.references[id.index()].span.start.offset < outer.start.offset
1168        });
1169        ReferencesInSpan {
1170            references: &self.references,
1171            ids: sorted[lower..].iter(),
1172            end: outer.end.offset,
1173        }
1174    }
1175
1176    /// Yield direct references for `command_span` whose spans are fully contained within `outer`.
1177    ///
1178    /// References recorded for nested commands are excluded because semantic
1179    /// stores them against their own command spans instead of the enclosing
1180    /// command.
1181    pub fn references_in_command_span(
1182        &self,
1183        command_span: Span,
1184        outer: Span,
1185    ) -> CommandReferencesInSpan<'_> {
1186        let command_span = self
1187            .command_references
1188            .contains_key(&SpanKey::new(command_span))
1189            .then_some(command_span)
1190            .or_else(|| {
1191                self.command_by_span(command_span)
1192                    .map(|id| self.command_span(id))
1193            });
1194        let ids = command_span
1195            .filter(|span| contains_span(*span, outer))
1196            .and_then(|span| self.command_references.get(&SpanKey::new(span)))
1197            .map(SmallVec::as_slice)
1198            .unwrap_or(&[]);
1199        CommandReferencesInSpan {
1200            references: &self.references,
1201            ids: ids.iter(),
1202            outer,
1203        }
1204    }
1205
1206    /// Yield every binding whose span is fully contained within `outer`.
1207    ///
1208    /// Backed by a lazily-built index sorted by binding start offset, so a
1209    /// per-span query costs `O(log n + matches)` rather than scanning every
1210    /// binding in the file.
1211    pub fn bindings_in_span(&self, outer: Span) -> BindingsInSpan<'_> {
1212        let sorted = self
1213            .bindings_sorted_by_start
1214            .get_or_init(|| build_bindings_sorted_by_start(&self.bindings));
1215        let lower = sorted
1216            .partition_point(|id| self.bindings[id.index()].span.start.offset < outer.start.offset);
1217        BindingsInSpan {
1218            bindings: &self.bindings,
1219            ids: sorted[lower..].iter(),
1220            end: outer.end.offset,
1221        }
1222    }
1223
1224    /// Returns the binding identified by `id`.
1225    pub fn binding(&self, id: BindingId) -> &Binding {
1226        &self.bindings[id.index()]
1227    }
1228
1229    /// Returns the binding introduced at exactly `span`, when such a definition is indexed.
1230    pub fn binding_for_definition_span(&self, span: Span) -> Option<BindingId> {
1231        let index = self
1232            .bindings_by_definition_span
1233            .get_or_init(|| build_bindings_by_definition_span(&self.bindings));
1234        index.get(&SpanKey::new(span)).copied()
1235    }
1236
1237    /// Returns the reference identified by `id`.
1238    pub fn reference(&self, id: ReferenceId) -> &Reference {
1239        &self.references[id.index()]
1240    }
1241
1242    /// Returns the binding resolved for `id`, if reference resolution succeeded.
1243    pub fn resolved_binding(&self, id: ReferenceId) -> Option<&Binding> {
1244        self.resolved
1245            .get(&id)
1246            .map(|binding| &self.bindings[binding.index()])
1247    }
1248
1249    /// Returns whether `id` names a predefined runtime array variable.
1250    pub fn reference_is_predefined_runtime_array(&self, id: ReferenceId) -> bool {
1251        self.predefined_runtime_refs.contains(&id)
1252            && self
1253                .references
1254                .get(id.index())
1255                .is_some_and(|reference| self.runtime.is_preinitialized_array(&reference.name))
1256    }
1257
1258    /// Returns whether `name` is provided by the shell runtime for this model's dialect.
1259    pub fn name_is_predefined_runtime(&self, name: &str) -> bool {
1260        self.runtime.is_preinitialized(&Name::from(name))
1261    }
1262
1263    /// Returns whether `name` is a well-known runtime-style name that typo suppression should
1264    /// ignore for this model's shell dialect.
1265    pub fn name_is_known_runtime(&self, name: &str) -> bool {
1266        self.runtime.is_known_runtime_name(&Name::from(name))
1267    }
1268
1269    /// Returns whether `id` is guarded by parameter-expansion syntax that suppresses missing-name
1270    /// diagnostics.
1271    pub fn is_guarded_parameter_reference(&self, id: ReferenceId) -> bool {
1272        self.guarded_parameter_refs.contains(&id)
1273    }
1274
1275    /// Returns whether `id` appears inside a defaulting parameter operand.
1276    pub fn is_defaulting_parameter_operand_reference(&self, id: ReferenceId) -> bool {
1277        self.defaulting_parameter_operand_refs.contains(&id)
1278    }
1279
1280    /// Returns bindings that may be targeted by indirect reads through `id`.
1281    pub fn indirect_targets_for_binding(&self, id: BindingId) -> &[BindingId] {
1282        self.indirect_targets_by_binding
1283            .get(&id)
1284            .map(Vec::as_slice)
1285            .unwrap_or(&[])
1286    }
1287
1288    /// Returns bindings that may be targeted by the indirect reference `id`.
1289    pub fn indirect_targets_for_reference(&self, id: ReferenceId) -> &[BindingId] {
1290        self.indirect_targets_by_reference
1291            .get(&id)
1292            .map(Vec::as_slice)
1293            .unwrap_or(&[])
1294    }
1295
1296    /// Returns bindings recorded for `name`, ordered by definition offset.
1297    pub fn bindings_for(&self, name: &Name) -> &[BindingId] {
1298        self.binding_index
1299            .get(name)
1300            .map(SmallVec::as_slice)
1301            .unwrap_or(&[])
1302    }
1303
1304    /// Returns the latest binding for `name` that is visible at `at`.
1305    pub fn visible_binding(&self, name: &Name, at: Span) -> Option<&Binding> {
1306        self.previous_visible_binding(name, at, None)
1307    }
1308
1309    /// Return binding candidates for a reference using lexical visibility first,
1310    /// then prior same-name bindings outside the reference scope.
1311    pub fn visible_candidate_bindings_for_reference(
1312        &self,
1313        reference: &Reference,
1314    ) -> Vec<BindingId> {
1315        let all_bindings = self.bindings_for(&reference.name);
1316        let binding_ids = self
1317            .ancestor_scopes(reference.scope)
1318            .filter_map(|scope| {
1319                all_bindings.iter().copied().rev().find(|binding_id| {
1320                    let binding = self.binding(*binding_id);
1321                    binding.scope == scope && self.binding_visible_at(*binding_id, reference.span)
1322                })
1323            })
1324            .collect::<Vec<_>>();
1325        if !binding_ids.is_empty() {
1326            return binding_ids;
1327        }
1328
1329        self.ancestor_scopes(reference.scope)
1330            .skip(1)
1331            .filter_map(|scope| {
1332                all_bindings.iter().copied().rev().find(|binding_id| {
1333                    let binding = self.binding(*binding_id);
1334                    binding.scope == scope && self.binding_visible_at(*binding_id, reference.span)
1335                })
1336            })
1337            .chain(all_bindings.iter().copied().filter(|binding_id| {
1338                let binding = self.binding(*binding_id);
1339                binding.scope != reference.scope
1340                    && binding.span.start.offset < reference.span.start.offset
1341            }))
1342            .collect::<FxHashSet<_>>()
1343            .into_iter()
1344            .collect::<Vec<_>>()
1345    }
1346
1347    #[doc(hidden)]
1348    /// Returns whether `binding_id` is lexically visible at `at`.
1349    #[doc(hidden)]
1350    pub fn binding_visible_at(&self, binding_id: BindingId, at: Span) -> bool {
1351        let binding = self.binding(binding_id);
1352        binding.span.start.offset <= at.start.offset
1353            && self
1354                .ancestor_scopes(self.scope_at(at.start.offset))
1355                .any(|scope| scope == binding.scope)
1356    }
1357
1358    /// Returns whether `binding_id` is cleared between its definition and `at`.
1359    #[doc(hidden)]
1360    pub fn binding_cleared_before(&self, binding_id: BindingId, at: Span) -> bool {
1361        let binding = self.binding(binding_id);
1362        self.cleared_variables
1363            .get(&(binding.scope, binding.name.clone()))
1364            .is_some_and(|cleared_offsets| {
1365                cleared_offsets.iter().any(|cleared_offset| {
1366                    *cleared_offset > binding.span.start.offset && *cleared_offset < at.start.offset
1367                })
1368            })
1369    }
1370
1371    /// Returns whether `binding_id` and `reference_id` were recorded under the same command span.
1372    #[doc(hidden)]
1373    pub fn binding_and_reference_share_command(
1374        &self,
1375        binding_id: BindingId,
1376        reference_id: ReferenceId,
1377    ) -> bool {
1378        self.command_bindings.iter().any(|(command, bindings)| {
1379            bindings.contains(&binding_id)
1380                && self
1381                    .command_references
1382                    .get(command)
1383                    .is_some_and(|references| references.contains(&reference_id))
1384        })
1385    }
1386
1387    /// Returns the previous visible binding for `name` at `at`, optionally ignoring one exact
1388    /// binding span.
1389    #[doc(hidden)]
1390    pub fn previous_visible_binding(
1391        &self,
1392        name: &Name,
1393        at: Span,
1394        ignored_binding_span: Option<Span>,
1395    ) -> Option<&Binding> {
1396        let scope = self.scope_at(at.start.offset);
1397        self.previous_visible_binding_id_in_scope_chain(
1398            name,
1399            scope,
1400            at.start.offset,
1401            ignored_binding_span,
1402        )
1403        .map(|binding_id| self.binding(binding_id))
1404    }
1405
1406    /// Returns the binding visible for an associative-array lookup in `current_scope`.
1407    #[doc(hidden)]
1408    pub fn visible_binding_for_assoc_lookup(
1409        &self,
1410        name: &Name,
1411        current_scope: ScopeId,
1412        at: Span,
1413    ) -> Option<&Binding> {
1414        if let Some(binding_id) =
1415            self.previous_assoc_lookup_binding_id_in_scope(current_scope, name, at.start.offset)
1416        {
1417            return Some(self.binding(binding_id));
1418        }
1419
1420        self.ancestor_scopes(current_scope)
1421            .skip(1)
1422            .find_map(|scope| {
1423                self.previous_visible_binding_id_in_scope(scope, name, at.start.offset, None)
1424            })
1425            .map(|binding_id| self.binding(binding_id))
1426    }
1427
1428    /// Returns a visible binding for a contextual lookup, including named callers.
1429    #[doc(hidden)]
1430    pub fn visible_binding_for_lookup(
1431        &self,
1432        name: &Name,
1433        current_scope: ScopeId,
1434        at: Span,
1435    ) -> Option<&Binding> {
1436        if let Some(binding_id) = self.previous_visible_binding_id_in_scope_chain(
1437            name,
1438            current_scope,
1439            at.start.offset,
1440            None,
1441        ) {
1442            return Some(self.binding(binding_id));
1443        }
1444
1445        self.visible_binding_from_named_callers(name, current_scope)
1446    }
1447
1448    /// Returns a visible binding that decides contextual associative lookups.
1449    #[doc(hidden)]
1450    pub fn visible_assoc_lookup_binding_for_lookup(
1451        &self,
1452        name: &Name,
1453        current_scope: ScopeId,
1454        at: Span,
1455    ) -> Option<&Binding> {
1456        if let Some(binding) = self.visible_binding_for_assoc_lookup(name, current_scope, at) {
1457            return Some(binding);
1458        }
1459
1460        self.visible_assoc_lookup_binding_from_named_callers(name, current_scope)
1461    }
1462
1463    /// Returns whether an associative binding is visible for a contextual array lookup.
1464    pub fn assoc_binding_visible_for_lookup(
1465        &self,
1466        name: &Name,
1467        current_scope: ScopeId,
1468        at: Span,
1469    ) -> bool {
1470        if let Some(visible) = self.assoc_binding_visible_in_scope(name, current_scope, at) {
1471            return visible;
1472        }
1473
1474        self.assoc_binding_visible_from_named_callers(name, current_scope)
1475    }
1476
1477    fn assoc_binding_visible_in_scope(
1478        &self,
1479        name: &Name,
1480        current_scope: ScopeId,
1481        at: Span,
1482    ) -> Option<bool> {
1483        self.visible_binding_for_assoc_lookup(name, current_scope, at)
1484            .map(|binding| binding.attributes.contains(BindingAttributes::ASSOC))
1485    }
1486
1487    fn assoc_binding_visible_from_named_callers(
1488        &self,
1489        name: &Name,
1490        current_scope: ScopeId,
1491    ) -> bool {
1492        let Some(function_names) = self.named_function_scope_names(current_scope) else {
1493            return false;
1494        };
1495
1496        let mut seen = AssocCallerSeenNames::new();
1497        let mut worklist = SmallVec::<[Name; 4]>::new();
1498        worklist.extend(function_names.iter().cloned());
1499
1500        while let Some(function_name) = worklist.pop() {
1501            if !seen.insert(&function_name) {
1502                continue;
1503            }
1504
1505            for call_site in self.call_sites_for(&function_name) {
1506                if let Some(binding) = self.visible_binding_for_assoc_lookup(
1507                    name,
1508                    call_site.scope,
1509                    call_site.name_span,
1510                ) {
1511                    if binding.attributes.contains(BindingAttributes::ASSOC) {
1512                        return true;
1513                    }
1514                    continue;
1515                }
1516
1517                if let Some(caller_names) = self.named_function_scope_names(call_site.scope) {
1518                    worklist.extend(caller_names.iter().cloned());
1519                }
1520            }
1521        }
1522
1523        false
1524    }
1525
1526    fn visible_assoc_lookup_binding_from_named_callers(
1527        &self,
1528        name: &Name,
1529        current_scope: ScopeId,
1530    ) -> Option<&Binding> {
1531        let function_names = self.named_function_scope_names(current_scope)?;
1532
1533        let mut seen = AssocCallerSeenNames::new();
1534        let mut worklist = SmallVec::<[Name; 4]>::new();
1535        worklist.extend(function_names.iter().cloned());
1536
1537        while let Some(function_name) = worklist.pop() {
1538            if !seen.insert(&function_name) {
1539                continue;
1540            }
1541
1542            for call_site in self.call_sites_for(&function_name) {
1543                if let Some(binding) = self.visible_binding_for_assoc_lookup(
1544                    name,
1545                    call_site.scope,
1546                    call_site.name_span,
1547                ) {
1548                    return Some(binding);
1549                }
1550
1551                if let Some(caller_names) = self.named_function_scope_names(call_site.scope) {
1552                    worklist.extend(caller_names.iter().cloned());
1553                }
1554            }
1555        }
1556
1557        None
1558    }
1559
1560    fn visible_binding_from_named_callers(
1561        &self,
1562        name: &Name,
1563        current_scope: ScopeId,
1564    ) -> Option<&Binding> {
1565        let function_names = self.named_function_scope_names(current_scope)?;
1566
1567        let mut seen = AssocCallerSeenNames::new();
1568        let mut worklist = SmallVec::<[Name; 4]>::new();
1569        worklist.extend(function_names.iter().cloned());
1570
1571        while let Some(function_name) = worklist.pop() {
1572            if !seen.insert(&function_name) {
1573                continue;
1574            }
1575
1576            for call_site in self.call_sites_for(&function_name) {
1577                if let Some(binding_id) = self.previous_visible_binding_id_in_scope_chain(
1578                    name,
1579                    call_site.scope,
1580                    call_site.name_span.start.offset,
1581                    None,
1582                ) {
1583                    return Some(self.binding(binding_id));
1584                }
1585
1586                if let Some(caller_names) = self.named_function_scope_names(call_site.scope) {
1587                    worklist.extend(caller_names.iter().cloned());
1588                }
1589            }
1590        }
1591
1592        None
1593    }
1594
1595    fn named_function_scope_names(&self, scope: ScopeId) -> Option<&[Name]> {
1596        self.ancestor_scopes(scope)
1597            .find_map(|scope_id| match &self.scope(scope_id).kind {
1598                ScopeKind::Function(FunctionScopeKind::Named(names)) => Some(names.as_slice()),
1599                _ => None,
1600            })
1601    }
1602
1603    /// Returns whether any binding for `name` exists in the model.
1604    pub fn defined_anywhere(&self, name: &Name) -> bool {
1605        self.binding_index.contains_key(name)
1606    }
1607
1608    /// Returns whether `name` is defined inside at least one function scope.
1609    pub fn defined_in_any_function(&self, name: &Name) -> bool {
1610        self.binding_index.get(name).is_some_and(|bindings| {
1611            bindings.iter().any(|binding| {
1612                matches!(
1613                    self.scopes[self.bindings[binding.index()].scope.index()].kind,
1614                    ScopeKind::Function(_)
1615                )
1616            })
1617        })
1618    }
1619
1620    /// Returns whether runtime behavior can consume `binding_id` even without a direct read in
1621    /// the current file.
1622    pub fn is_runtime_consumed_binding(&self, binding_id: BindingId) -> bool {
1623        self.bindings
1624            .get(binding_id.index())
1625            .is_some_and(|binding| self.runtime.is_always_used_binding(&binding.name))
1626    }
1627
1628    /// Returns whether `name` has a required-read reference in `scope` before `offset`.
1629    pub fn required_before(&self, name: &Name, scope: ScopeId, offset: usize) -> bool {
1630        self.references.iter().any(|reference| {
1631            reference.scope == scope
1632                && &reference.name == name
1633                && matches!(reference.kind, ReferenceKind::RequiredRead)
1634                && reference.span.start.offset < offset
1635        })
1636    }
1637
1638    /// Returns whether an ancestor scope outside `scope` defines `name`.
1639    pub fn maybe_defined_outside(&self, name: &Name, scope: ScopeId) -> bool {
1640        self.ancestor_scopes(scope)
1641            .skip(1)
1642            .any(|scope| self.scopes[scope.index()].bindings.contains_key(name))
1643    }
1644
1645    /// Returns references that did not resolve to any binding.
1646    pub fn unresolved_references(&self) -> &[ReferenceId] {
1647        &self.unresolved
1648    }
1649
1650    /// Returns the innermost semantic scope containing `offset`.
1651    pub fn scope_at(&self, offset: usize) -> ScopeId {
1652        self.scope_lookup
1653            .scope_at(&self.scopes, offset)
1654            .unwrap_or(ScopeId(0))
1655    }
1656
1657    /// Returns the kind metadata for `scope`.
1658    pub fn scope_kind(&self, scope: ScopeId) -> &ScopeKind {
1659        &self.scopes[scope.index()].kind
1660    }
1661
1662    fn scope_is_transient(&self, scope: ScopeId) -> bool {
1663        matches!(
1664            self.scope_kind(scope),
1665            ScopeKind::Subshell | ScopeKind::CommandSubstitution | ScopeKind::Pipeline
1666        )
1667    }
1668
1669    /// Iterates `scope` and then each lexical ancestor scope outward.
1670    pub fn ancestor_scopes(&self, scope: ScopeId) -> impl Iterator<Item = ScopeId> + '_ {
1671        ancestor_scopes(&self.scopes, scope)
1672    }
1673
1674    /// Returns whether `scope` is equal to `ancestor_scope` or nested within it.
1675    pub fn scope_is_in_scope_or_descendant(&self, scope: ScopeId, ancestor_scope: ScopeId) -> bool {
1676        self.ancestor_scopes(scope)
1677            .any(|scope| scope == ancestor_scope)
1678    }
1679
1680    /// Returns whether `scope` is strictly nested within `ancestor_scope`.
1681    pub fn scope_is_descendant_of(&self, scope: ScopeId, ancestor_scope: ScopeId) -> bool {
1682        scope != ancestor_scope && self.scope_is_in_scope_or_descendant(scope, ancestor_scope)
1683    }
1684
1685    /// Returns the nearest enclosing function scope for `scope`, if one exists.
1686    pub fn enclosing_function_scope(&self, scope: ScopeId) -> Option<ScopeId> {
1687        enclosing_function_scope(&self.scopes, scope)
1688    }
1689
1690    /// Iterates transient ancestor scopes between `scope` and its enclosing function boundary.
1691    #[doc(hidden)]
1692    pub fn transient_ancestor_scopes_within_function(
1693        &self,
1694        scope: ScopeId,
1695    ) -> impl Iterator<Item = ScopeId> + '_ {
1696        self.ancestor_scopes(scope)
1697            .take_while(|scope_id| !matches!(self.scope_kind(*scope_id), ScopeKind::Function(_)))
1698            .filter(|scope_id| self.scope_is_transient(*scope_id))
1699    }
1700
1701    /// Returns the innermost transient ancestor scope before crossing a function boundary.
1702    #[doc(hidden)]
1703    pub fn innermost_transient_scope_within_function(&self, scope: ScopeId) -> Option<ScopeId> {
1704        self.transient_ancestor_scopes_within_function(scope).next()
1705    }
1706
1707    /// Returns the enclosing function scope only when no transient boundary intervenes.
1708    #[doc(hidden)]
1709    pub fn enclosing_function_scope_without_transient_boundary(
1710        &self,
1711        scope: ScopeId,
1712    ) -> Option<ScopeId> {
1713        if self
1714            .transient_ancestor_scopes_within_function(scope)
1715            .next()
1716            .is_some()
1717        {
1718            None
1719        } else {
1720            self.enclosing_function_scope(scope)
1721        }
1722    }
1723
1724    fn previous_visible_binding_id_in_scope_chain(
1725        &self,
1726        name: &Name,
1727        scope: ScopeId,
1728        offset: usize,
1729        ignored_binding_span: Option<Span>,
1730    ) -> Option<BindingId> {
1731        self.ancestor_scopes(scope).find_map(|scope_id| {
1732            self.previous_visible_binding_id_in_scope(scope_id, name, offset, ignored_binding_span)
1733        })
1734    }
1735
1736    fn previous_visible_binding_id_in_scope(
1737        &self,
1738        scope: ScopeId,
1739        name: &Name,
1740        offset: usize,
1741        ignored_binding_span: Option<Span>,
1742    ) -> Option<BindingId> {
1743        let bindings = self.scopes[scope.index()].bindings.get(name)?;
1744        previous_visible_binding_id_from_slice(
1745            &self.bindings,
1746            bindings,
1747            offset,
1748            ignored_binding_span,
1749        )
1750    }
1751
1752    fn previous_assoc_lookup_binding_id_in_scope(
1753        &self,
1754        scope: ScopeId,
1755        name: &Name,
1756        offset: usize,
1757    ) -> Option<BindingId> {
1758        let bindings = self
1759            .assoc_lookup_binding_index()
1760            .blocking_bindings_by_scope
1761            .get(scope.index())
1762            .and_then(|bindings_by_name| bindings_by_name.get(name))?;
1763        previous_visible_binding_id_from_slice(&self.bindings, bindings, offset, None)
1764    }
1765
1766    fn assoc_lookup_binding_index(&self) -> &AssocLookupBindingIndex {
1767        self.assoc_lookup_binding_index.get_or_init(|| {
1768            let blocking_bindings_by_scope = self
1769                .scopes
1770                .iter()
1771                .map(|scope| {
1772                    let mut bindings_by_name = FxHashMap::default();
1773                    for (name, bindings) in &scope.bindings {
1774                        let filtered = bindings
1775                            .iter()
1776                            .copied()
1777                            .filter(|binding_id| {
1778                                binding_blocks_same_scope_assoc_lookup(
1779                                    &self.bindings[binding_id.index()],
1780                                )
1781                            })
1782                            .collect::<Vec<_>>();
1783                        if !filtered.is_empty() {
1784                            bindings_by_name.insert(name.clone(), filtered.into_boxed_slice());
1785                        }
1786                    }
1787                    bindings_by_name
1788                })
1789                .collect();
1790
1791            AssocLookupBindingIndex {
1792                blocking_bindings_by_scope,
1793            }
1794        })
1795    }
1796
1797    /// Returns the most specific recorded flow context associated with `span`.
1798    pub fn flow_context_at(&self, span: &Span) -> Option<&FlowContext> {
1799        self.flow_contexts
1800            .iter()
1801            .rfind(|(candidate, _)| candidate == span)
1802            .map(|(_, context)| context)
1803            .or_else(|| {
1804                self.flow_contexts
1805                    .iter()
1806                    .enumerate()
1807                    .filter(|(_, (candidate, _))| {
1808                        contains_span(*candidate, *span) || contains_span(*span, *candidate)
1809                    })
1810                    .min_by_key(|(index, (candidate, _))| {
1811                        (
1812                            candidate.end.offset.saturating_sub(candidate.start.offset),
1813                            std::cmp::Reverse(*index),
1814                        )
1815                    })
1816                    .map(|(_, (_, context))| context)
1817            })
1818    }
1819
1820    fn add_imported_binding(
1821        &mut self,
1822        provided: &ProvidedBinding,
1823        scope: ScopeId,
1824        span: Span,
1825        command_span: Option<Span>,
1826        origin_paths: Vec<PathBuf>,
1827        file_entry_contract: bool,
1828    ) -> BindingId {
1829        let mut attributes = BindingAttributes::empty();
1830        if provided.certainty == ContractCertainty::Possible {
1831            attributes |= BindingAttributes::IMPORTED_POSSIBLE;
1832        }
1833        if provided.kind == ProvidedBindingKind::Function {
1834            attributes |= BindingAttributes::IMPORTED_FUNCTION;
1835        }
1836        if file_entry_contract {
1837            attributes |= BindingAttributes::IMPORTED_FILE_ENTRY;
1838            if provided.file_entry_initialization == FileEntryBindingInitialization::Initialized {
1839                attributes |= BindingAttributes::IMPORTED_FILE_ENTRY_INITIALIZED;
1840            }
1841        }
1842
1843        let id = BindingId(self.bindings.len() as u32);
1844        self.bindings.push(Binding {
1845            id,
1846            name: provided.name.clone(),
1847            kind: BindingKind::Imported,
1848            origin: BindingOrigin::Imported {
1849                definition_span: span,
1850            },
1851            scope,
1852            span,
1853            references: Vec::new(),
1854            attributes,
1855        });
1856        insert_binding_id_sorted(
1857            self.binding_index.entry(provided.name.clone()).or_default(),
1858            &self.bindings,
1859            id,
1860        );
1861        insert_binding_id_sorted(
1862            self.scopes[scope.index()]
1863                .bindings
1864                .entry(provided.name.clone())
1865                .or_default(),
1866            &self.bindings,
1867            id,
1868        );
1869        if provided.kind == ProvidedBindingKind::Function {
1870            insert_binding_id_sorted(
1871                self.functions.entry(provided.name.clone()).or_default(),
1872                &self.bindings,
1873                id,
1874            );
1875        }
1876        if let Some(command_span) = command_span {
1877            self.command_bindings
1878                .entry(SpanKey::new(command_span))
1879                .or_default()
1880                .push(id);
1881        }
1882        if !origin_paths.is_empty() {
1883            self.import_origins_by_binding.insert(id, origin_paths);
1884        }
1885        self.bindings_by_definition_span.take();
1886        id
1887    }
1888
1889    pub(crate) fn apply_file_entry_contract(&mut self, contract: FileContract, file: &File) {
1890        if contract.required_reads.is_empty()
1891            && contract.provided_bindings.is_empty()
1892            && contract.provided_functions.is_empty()
1893            && !contract.externally_consumed_bindings
1894            && contract.externally_consumed_binding_names.is_empty()
1895            && contract.externally_consumed_binding_prefixes.is_empty()
1896        {
1897            return;
1898        }
1899
1900        if contract.externally_consumed_bindings {
1901            self.mark_file_entry_consumed_bindings();
1902        }
1903        if !contract.externally_consumed_binding_names.is_empty() {
1904            self.mark_file_entry_consumed_binding_names(
1905                &contract.externally_consumed_binding_names,
1906            );
1907        }
1908        if !contract.externally_consumed_binding_prefixes.is_empty() {
1909            self.mark_file_entry_consumed_binding_prefixes(
1910                &contract.externally_consumed_binding_prefixes,
1911            );
1912        }
1913
1914        let mut synthetic_reads = self.synthetic_reads.clone();
1915        for name in contract.required_reads {
1916            synthetic_reads.push(SyntheticRead {
1917                scope: ScopeId(0),
1918                span: file.span,
1919                name,
1920            });
1921        }
1922
1923        let entry_span = Span::from_positions(file.span.start, file.span.start);
1924        let mut entry_bindings = self.entry_bindings.clone();
1925        let function_origin_paths = contract
1926            .provided_functions
1927            .iter()
1928            .map(|function| (function.name.clone(), function.origin_paths.clone()))
1929            .collect::<FxHashMap<_, _>>();
1930        let mut provided_bindings = contract.provided_bindings;
1931        for function in contract.provided_functions {
1932            if !provided_bindings.iter().any(|binding| {
1933                binding.kind == ProvidedBindingKind::Function && binding.name == function.name
1934            }) {
1935                provided_bindings.push(ProvidedBinding::new(
1936                    function.name,
1937                    ProvidedBindingKind::Function,
1938                    ContractCertainty::Definite,
1939                ));
1940            }
1941        }
1942        for binding in &provided_bindings {
1943            let origin_paths = function_origin_paths
1944                .get(&binding.name)
1945                .cloned()
1946                .unwrap_or_default();
1947            let id = self.add_imported_binding(
1948                binding,
1949                ScopeId(0),
1950                entry_span,
1951                None,
1952                origin_paths,
1953                true,
1954            );
1955            entry_bindings.push(id);
1956        }
1957
1958        self.set_synthetic_reads(dedup_synthetic_reads(synthetic_reads));
1959        self.set_entry_bindings(entry_bindings);
1960        self.invalidate_function_binding_lookup();
1961        self.resolve_unresolved_references();
1962        self.invalidate_semantic_caches();
1963    }
1964
1965    fn mark_file_entry_consumed_bindings(&mut self) {
1966        for binding in &mut self.bindings {
1967            if file_entry_contract_can_consume_binding(binding) {
1968                binding.attributes |= BindingAttributes::EXTERNALLY_CONSUMED;
1969            }
1970        }
1971        self.heuristic_unused_assignments.retain(|binding_id| {
1972            !self.bindings[binding_id.index()]
1973                .attributes
1974                .contains(BindingAttributes::EXTERNALLY_CONSUMED)
1975        });
1976    }
1977
1978    fn mark_file_entry_consumed_binding_names(&mut self, names: &[Name]) {
1979        for binding in &mut self.bindings {
1980            if file_entry_contract_can_consume_binding(binding) && names.contains(&binding.name) {
1981                binding.attributes |= BindingAttributes::EXTERNALLY_CONSUMED;
1982            }
1983        }
1984        self.heuristic_unused_assignments.retain(|binding_id| {
1985            !self.bindings[binding_id.index()]
1986                .attributes
1987                .contains(BindingAttributes::EXTERNALLY_CONSUMED)
1988        });
1989    }
1990
1991    fn mark_file_entry_consumed_binding_prefixes(&mut self, prefixes: &[Name]) {
1992        for binding in &mut self.bindings {
1993            if file_entry_contract_can_consume_binding(binding)
1994                && prefixes
1995                    .iter()
1996                    .any(|prefix| binding.name.as_str().starts_with(prefix.as_str()))
1997            {
1998                binding.attributes |= BindingAttributes::EXTERNALLY_CONSUMED;
1999            }
2000        }
2001        self.heuristic_unused_assignments.retain(|binding_id| {
2002            !self.bindings[binding_id.index()]
2003                .attributes
2004                .contains(BindingAttributes::EXTERNALLY_CONSUMED)
2005        });
2006    }
2007
2008    pub(crate) fn apply_source_contracts(
2009        &mut self,
2010        contracts: source_closure::SourceClosureContracts,
2011    ) {
2012        if contracts
2013            .requesting_file_contract
2014            .externally_consumed_bindings
2015        {
2016            self.mark_file_entry_consumed_bindings();
2017        }
2018        if !contracts
2019            .requesting_file_contract
2020            .externally_consumed_binding_names
2021            .is_empty()
2022        {
2023            self.mark_file_entry_consumed_binding_names(
2024                &contracts
2025                    .requesting_file_contract
2026                    .externally_consumed_binding_names,
2027            );
2028        }
2029        if !contracts
2030            .requesting_file_contract
2031            .externally_consumed_binding_prefixes
2032            .is_empty()
2033        {
2034            self.mark_file_entry_consumed_binding_prefixes(
2035                &contracts
2036                    .requesting_file_contract
2037                    .externally_consumed_binding_prefixes,
2038            );
2039        }
2040
2041        if contracts.synthetic_reads.is_empty()
2042            && contracts.imported_bindings.is_empty()
2043            && !contracts
2044                .requesting_file_contract
2045                .externally_consumed_bindings
2046            && contracts
2047                .requesting_file_contract
2048                .externally_consumed_binding_names
2049                .is_empty()
2050            && contracts
2051                .requesting_file_contract
2052                .externally_consumed_binding_prefixes
2053                .is_empty()
2054            && contracts.dependency_paths.is_empty()
2055            && contracts.source_ref_resolutions.is_empty()
2056            && contracts.source_ref_explicitness.is_empty()
2057            && contracts.source_ref_diagnostic_classes.is_empty()
2058        {
2059            return;
2060        }
2061
2062        let mut merged_reads = self.synthetic_reads.clone();
2063        merged_reads.extend(contracts.synthetic_reads);
2064        self.set_synthetic_reads(dedup_synthetic_reads(merged_reads));
2065        if !contracts.dependency_paths.is_empty() {
2066            for path in contracts.dependency_paths {
2067                if !self.imported_dependency_paths.contains(&path) {
2068                    self.imported_dependency_paths.push(path);
2069                }
2070            }
2071            self.imported_dependency_paths.sort();
2072            self.imported_dependency_paths.dedup();
2073        }
2074
2075        if !contracts.source_ref_resolutions.is_empty() {
2076            debug_assert_eq!(
2077                contracts.source_ref_resolutions.len(),
2078                self.source_refs.len()
2079            );
2080            for (source_ref, resolution) in self
2081                .source_refs
2082                .iter_mut()
2083                .zip(contracts.source_ref_resolutions)
2084            {
2085                source_ref.resolution = resolution;
2086            }
2087        }
2088        if !contracts.source_ref_explicitness.is_empty() {
2089            debug_assert_eq!(
2090                contracts.source_ref_explicitness.len(),
2091                self.source_refs.len()
2092            );
2093            for (source_ref, explicitly_provided) in self
2094                .source_refs
2095                .iter_mut()
2096                .zip(contracts.source_ref_explicitness)
2097            {
2098                source_ref.explicitly_provided = explicitly_provided;
2099            }
2100        }
2101        if !contracts.source_ref_diagnostic_classes.is_empty() {
2102            debug_assert_eq!(
2103                contracts.source_ref_diagnostic_classes.len(),
2104                self.source_refs.len()
2105            );
2106            for (source_ref, diagnostic_class) in self
2107                .source_refs
2108                .iter_mut()
2109                .zip(contracts.source_ref_diagnostic_classes)
2110            {
2111                source_ref.diagnostic_class = diagnostic_class;
2112            }
2113        }
2114
2115        for site in contracts.imported_bindings {
2116            self.add_imported_binding(
2117                &site.binding,
2118                site.scope,
2119                site.span,
2120                Some(site.span),
2121                site.origin_paths,
2122                false,
2123            );
2124        }
2125        self.invalidate_function_binding_lookup();
2126        self.resolve_unresolved_references();
2127        self.invalidate_semantic_caches();
2128    }
2129
2130    fn invalidate_function_binding_lookup(&mut self) {
2131        self.unconditional_function_bindings.take();
2132        self.function_bindings_by_scope.take();
2133        self.visible_function_call_bindings.take();
2134        self.function_definition_binding_ids.take();
2135    }
2136
2137    fn invalidate_semantic_caches(&mut self) {
2138        self.call_graph.take();
2139        self.zsh_option_analysis.take();
2140        self.zsh_runtime_by_function.take();
2141        self.zsh_runtime_function_summaries.take();
2142    }
2143
2144    fn resolve_unresolved_references(&mut self) {
2145        let unresolved = std::mem::take(&mut self.unresolved);
2146        for reference_id in unresolved {
2147            let reference = &self.references[reference_id.index()];
2148            let resolved =
2149                self.resolve_binding_at(&reference.name, reference.scope, reference.span);
2150            if let Some(binding_id) = resolved {
2151                self.resolved.insert(reference_id, binding_id);
2152                self.bindings[binding_id.index()]
2153                    .references
2154                    .push(reference_id);
2155            } else {
2156                self.unresolved.push(reference_id);
2157            }
2158        }
2159    }
2160
2161    fn resolve_binding_at(&self, name: &Name, scope: ScopeId, span: Span) -> Option<BindingId> {
2162        for scope in self.ancestor_scopes(scope) {
2163            let Some(bindings) = self.scopes[scope.index()].bindings.get(name) else {
2164                continue;
2165            };
2166
2167            for binding in bindings.iter().rev().copied() {
2168                if self.bindings[binding.index()].span.start.offset <= span.start.offset {
2169                    return Some(binding);
2170                }
2171            }
2172        }
2173        None
2174    }
2175
2176    /// Returns same-name function-definition bindings for `name`, ordered by definition offset.
2177    pub fn function_definitions(&self, name: &Name) -> &[BindingId] {
2178        self.functions
2179            .get(name)
2180            .map(SmallVec::as_slice)
2181            .unwrap_or(&[])
2182    }
2183
2184    /// Returns recorded call sites for `name`.
2185    pub fn call_sites_for(&self, name: &Name) -> &[CallSite] {
2186        self.call_sites
2187            .get(name)
2188            .map(SmallVec::as_slice)
2189            .unwrap_or(&[])
2190    }
2191
2192    /// Returns the current call-graph summary for the model.
2193    pub fn call_graph(&self) -> &CallGraph {
2194        self.call_graph.get_or_init(|| {
2195            build_call_graph(
2196                &self.scopes,
2197                &self.bindings,
2198                &self.functions,
2199                &self.call_sites,
2200            )
2201        })
2202    }
2203
2204    /// Returns declaration commands recorded in the file.
2205    pub fn declarations(&self) -> &[Declaration] {
2206        &self.declarations
2207    }
2208
2209    /// Returns the declaration recorded for the command with syntax span `span`.
2210    pub fn declaration_for_command_span(&self, span: Span) -> Option<&Declaration> {
2211        let index = self
2212            .declarations_by_command_span
2213            .get_or_init(|| build_declarations_by_command_span(&self.declarations));
2214        index
2215            .get(&SpanKey::new(span))
2216            .map(|declaration_index| &self.declarations[*declaration_index])
2217    }
2218
2219    /// Returns the function-definition binding recorded for command span `span`, if any.
2220    pub fn function_definition_binding_for_command_span(&self, span: Span) -> Option<BindingId> {
2221        self.command_bindings
2222            .get(&SpanKey::new(span))
2223            .and_then(|bindings| {
2224                bindings.iter().copied().find(|binding_id| {
2225                    matches!(
2226                        self.bindings[binding_id.index()].kind,
2227                        BindingKind::FunctionDefinition
2228                    )
2229                })
2230            })
2231    }
2232
2233    /// Returns source-like file references discovered in the script.
2234    pub fn source_refs(&self) -> &[SourceRef] {
2235        &self.source_refs
2236    }
2237
2238    /// Returns synthetic reads introduced by contracts or semantic modeling.
2239    pub fn synthetic_reads(&self) -> &[SyntheticRead] {
2240        &self.synthetic_reads
2241    }
2242
2243    /// Returns origin paths that contributed the imported binding `id`.
2244    pub fn import_origins_for_binding(&self, id: BindingId) -> &[PathBuf] {
2245        self.import_origins_by_binding
2246            .get(&id)
2247            .map(Vec::as_slice)
2248            .unwrap_or(&[])
2249    }
2250
2251    /// Returns helper and plugin files that influenced imported semantic contracts.
2252    pub fn imported_dependency_paths(&self) -> &[PathBuf] {
2253        &self.imported_dependency_paths
2254    }
2255
2256    /// Returns flattened statement-sequence commands recorded during traversal.
2257    pub fn statement_sequence_commands(&self) -> &[StatementSequenceCommand] {
2258        self.recorded_program.statement_sequence_commands()
2259    }
2260
2261    /// Returns the number of recorded semantic commands.
2262    pub fn command_count(&self) -> usize {
2263        self.recorded_program.commands().len()
2264    }
2265
2266    pub(crate) fn recorded_program(&self) -> &RecordedProgram {
2267        &self.recorded_program
2268    }
2269
2270    pub(crate) fn set_synthetic_reads(&mut self, synthetic_reads: Vec<SyntheticRead>) {
2271        self.synthetic_reads = synthetic_reads;
2272    }
2273
2274    fn set_entry_bindings(&mut self, entry_bindings: Vec<BindingId>) {
2275        self.entry_bindings = entry_bindings;
2276    }
2277
2278    fn function_binding_lookup(&self) -> FunctionBindingLookup<'_> {
2279        FunctionBindingLookup {
2280            program: &self.recorded_program,
2281            scopes: &self.scopes,
2282            bindings: &self.bindings,
2283            call_sites: &self.call_sites,
2284            unconditional_function_bindings: self.unconditional_function_bindings(),
2285            function_bindings_by_scope: self.function_binding_scope_index(),
2286        }
2287    }
2288
2289    fn unconditional_function_bindings(&self) -> &FxHashSet<BindingId> {
2290        self.unconditional_function_bindings.get_or_init(|| {
2291            function_resolution::collect_unconditional_function_bindings(
2292                &self.recorded_program,
2293                &self.command_bindings,
2294                &self.bindings,
2295            )
2296        })
2297    }
2298
2299    pub(crate) fn function_binding_scope_index(
2300        &self,
2301    ) -> &FxHashMap<ScopeId, SmallVec<[BindingId; 2]>> {
2302        self.function_bindings_by_scope
2303            .get_or_init(|| function_resolution::function_bindings_by_scope(&self.recorded_program))
2304    }
2305
2306    pub(crate) fn visible_function_call_bindings(&self) -> &FxHashMap<SpanKey, BindingId> {
2307        self.visible_function_call_bindings.get_or_init(|| {
2308            self.function_binding_lookup()
2309                .visible_function_call_bindings()
2310        })
2311    }
2312
2313    fn dataflow_context<'a>(&'a self, cfg: &'a ControlFlowGraph) -> DataflowContext<'a> {
2314        DataflowContext {
2315            cfg,
2316            runtime: &self.runtime,
2317            scopes: &self.scopes,
2318            bindings: &self.bindings,
2319            references: &self.references,
2320            predefined_runtime_refs: &self.predefined_runtime_refs,
2321            guarded_parameter_refs: &self.guarded_parameter_refs,
2322            parameter_guard_flow_refs: &self.parameter_guard_flow_refs,
2323            self_referential_assignment_refs: &self.self_referential_assignment_refs,
2324            resolved: &self.resolved,
2325            call_sites: &self.call_sites,
2326            visible_function_call_bindings: self.visible_function_call_bindings(),
2327            function_body_scopes: &self.recorded_program.function_body_scopes,
2328            indirect_targets_by_reference: &self.indirect_targets_by_reference,
2329            array_like_indirect_expansion_refs: &self.array_like_indirect_expansion_refs,
2330            synthetic_reads: &self.synthetic_reads,
2331            entry_bindings: &self.entry_bindings,
2332        }
2333    }
2334}
2335
2336fn file_entry_contract_can_consume_binding(binding: &Binding) -> bool {
2337    if binding.attributes.contains(BindingAttributes::LOCAL) {
2338        return false;
2339    }
2340
2341    file_entry_contract_can_consume_exact_binding(binding)
2342}
2343
2344fn file_entry_contract_can_consume_exact_binding(binding: &Binding) -> bool {
2345    matches!(
2346        binding.kind,
2347        BindingKind::Assignment
2348            | BindingKind::ArrayAssignment
2349            | BindingKind::AppendAssignment
2350            | BindingKind::ParameterDefaultAssignment
2351            | BindingKind::LoopVariable
2352            | BindingKind::ReadTarget
2353            | BindingKind::MapfileTarget
2354            | BindingKind::PrintfTarget
2355            | BindingKind::GetoptsTarget
2356            | BindingKind::ArithmeticAssignment
2357            | BindingKind::Declaration(_)
2358    )
2359}
2360
2361#[doc(hidden)]
2362pub fn build_with_observer<'a>(
2363    file: &'a File,
2364    source: &'a str,
2365    indexer: &Indexer,
2366    observer: &mut dyn TraversalObserver<'a>,
2367) -> SemanticModel {
2368    build_with_observer_with_options(
2369        file,
2370        source,
2371        indexer,
2372        observer,
2373        SemanticBuildOptions::default(),
2374    )
2375}
2376
2377#[doc(hidden)]
2378pub fn build_with_observer_with_options<'a>(
2379    file: &'a File,
2380    source: &'a str,
2381    indexer: &Indexer,
2382    observer: &mut dyn TraversalObserver<'a>,
2383    options: SemanticBuildOptions<'_>,
2384) -> SemanticModel {
2385    build_semantic_model(file, source, indexer, observer, options)
2386}
2387
2388#[doc(hidden)]
2389pub fn build_with_observer_at_path<'a>(
2390    file: &'a File,
2391    source: &'a str,
2392    indexer: &Indexer,
2393    observer: &mut dyn TraversalObserver<'a>,
2394    source_path: Option<&Path>,
2395) -> SemanticModel {
2396    build_with_observer_at_path_with_resolver(file, source, indexer, observer, source_path, None)
2397}
2398
2399#[doc(hidden)]
2400pub fn build_with_observer_at_path_with_resolver<'a>(
2401    file: &'a File,
2402    source: &'a str,
2403    indexer: &Indexer,
2404    observer: &mut dyn TraversalObserver<'a>,
2405    source_path: Option<&Path>,
2406    source_path_resolver: Option<&(dyn SourcePathResolver + Send + Sync)>,
2407) -> SemanticModel {
2408    build_semantic_model(
2409        file,
2410        source,
2411        indexer,
2412        observer,
2413        SemanticBuildOptions {
2414            source_path,
2415            source_path_resolver,
2416            plugin_resolver: None,
2417            file_entry_contract: None,
2418            file_entry_contract_collector: None,
2419            file_entry_contract_collector_factory: None,
2420            analyzed_paths: None,
2421            shell_profile: None,
2422            resolve_source_closure: true,
2423        },
2424    )
2425}
2426
2427fn build_semantic_model<'a>(
2428    file: &'a File,
2429    source: &'a str,
2430    indexer: &Indexer,
2431    observer: &mut dyn TraversalObserver<'a>,
2432    options: SemanticBuildOptions<'_>,
2433) -> SemanticModel {
2434    let SemanticBuildOptions {
2435        source_path,
2436        source_path_resolver,
2437        plugin_resolver,
2438        file_entry_contract,
2439        mut file_entry_contract_collector,
2440        file_entry_contract_collector_factory,
2441        analyzed_paths,
2442        shell_profile,
2443        resolve_source_closure,
2444    } = options;
2445    let mut model = build_semantic_model_base(
2446        file,
2447        source,
2448        indexer,
2449        observer,
2450        source_path,
2451        shell_profile.clone(),
2452        file_entry_contract_collector
2453            .as_mut()
2454            .map(|collector| &mut **collector as &mut dyn FileEntryContractCollector),
2455    );
2456    if let Some(contract) = file_entry_contract {
2457        model.apply_file_entry_contract(contract, file);
2458    }
2459    if let Some(contract) = file_entry_contract_collector
2460        .as_ref()
2461        .and_then(|collector| collector.finish())
2462    {
2463        model.apply_file_entry_contract(contract, file);
2464    }
2465    if let Some(source_path) = source_path {
2466        let contracts = if resolve_source_closure {
2467            source_closure::collect_source_closure_contracts(
2468                &model,
2469                file,
2470                source,
2471                source_path,
2472                source_closure::SourceClosureResolverConfig {
2473                    source_path_resolver,
2474                    plugin_resolver,
2475                    file_entry_contract_collector_factory,
2476                    analyzed_paths,
2477                },
2478            )
2479        } else {
2480            let (source_ref_resolutions, source_ref_explicitness, source_ref_diagnostic_classes) =
2481                source_closure::collect_source_ref_metadata(
2482                    &model,
2483                    source_path,
2484                    source_path_resolver,
2485                    analyzed_paths,
2486                );
2487            source_closure::SourceClosureContracts::from_source_ref_metadata(
2488                source_ref_resolutions,
2489                source_ref_explicitness,
2490                source_ref_diagnostic_classes,
2491            )
2492        };
2493        model.apply_source_contracts(contracts);
2494    }
2495    model
2496}
2497
2498pub(crate) fn build_semantic_model_base<'a, 'observer>(
2499    file: &'a File,
2500    source: &'a str,
2501    indexer: &Indexer,
2502    observer: &'observer mut dyn TraversalObserver<'a>,
2503    source_path: Option<&Path>,
2504    shell_profile: Option<ShellProfile>,
2505    file_entry_contract_collector: Option<&'observer mut dyn FileEntryContractCollector>,
2506) -> SemanticModel {
2507    let shell_profile = shell_profile.unwrap_or_else(|| infer_shell_profile(source, source_path));
2508    let built = SemanticModelBuilder::build(
2509        file,
2510        source,
2511        indexer,
2512        observer,
2513        file_entry_contract_collector,
2514        bash_runtime_vars_enabled(source, source_path),
2515        shell_profile,
2516    );
2517    SemanticModel::from_build_output(built)
2518}
2519
2520fn infer_shell_profile(source: &str, path: Option<&Path>) -> ShellProfile {
2521    let dialect = infer_parse_dialect_from_source(source, path);
2522    ShellProfile::native(dialect)
2523}
2524
2525fn infer_parse_dialect_from_source(
2526    source: &str,
2527    path: Option<&Path>,
2528) -> shuck_parser::ShellDialect {
2529    if let Some(interpreter) = shebang_interpreter(source) {
2530        return parse_dialect_from_name(interpreter).unwrap_or(shuck_parser::ShellDialect::Bash);
2531    }
2532
2533    infer_parse_dialect_from_path(path).unwrap_or(shuck_parser::ShellDialect::Bash)
2534}
2535
2536pub(crate) fn infer_explicit_parse_dialect_from_source(
2537    source: &str,
2538    path: Option<&Path>,
2539) -> Option<shuck_parser::ShellDialect> {
2540    if let Some(interpreter) = shebang_interpreter(source)
2541        && let Some(dialect) = parse_dialect_from_name(interpreter)
2542    {
2543        return Some(dialect);
2544    }
2545
2546    infer_parse_dialect_from_path(path)
2547}
2548
2549fn shebang_interpreter(source: &str) -> Option<&str> {
2550    let first_line_end = source.find('\n').unwrap_or(source.len());
2551    let first_line = source.get(..first_line_end)?;
2552    shuck_parser::shebang::interpreter_name(first_line.strip_suffix('\r').unwrap_or(first_line))
2553}
2554
2555fn infer_parse_dialect_from_path(path: Option<&Path>) -> Option<shuck_parser::ShellDialect> {
2556    match path
2557        .and_then(|path| path.extension().and_then(|ext| ext.to_str()))
2558        .map(|ext| ext.to_ascii_lowercase())
2559        .as_deref()
2560    {
2561        Some("sh" | "dash" | "ksh") => Some(shuck_parser::ShellDialect::Posix),
2562        Some("mksh") => Some(shuck_parser::ShellDialect::Mksh),
2563        Some("bash") => Some(shuck_parser::ShellDialect::Bash),
2564        Some("zsh") => Some(shuck_parser::ShellDialect::Zsh),
2565        _ => None,
2566    }
2567}
2568
2569fn parse_dialect_from_name(name: &str) -> Option<shuck_parser::ShellDialect> {
2570    match name.to_ascii_lowercase().as_str() {
2571        "sh" | "dash" | "ksh" | "posix" => Some(shuck_parser::ShellDialect::Posix),
2572        "mksh" => Some(shuck_parser::ShellDialect::Mksh),
2573        "bash" => Some(shuck_parser::ShellDialect::Bash),
2574        "zsh" => Some(shuck_parser::ShellDialect::Zsh),
2575        _ => None,
2576    }
2577}
2578
2579fn bash_runtime_vars_enabled(source: &str, path: Option<&Path>) -> bool {
2580    infer_bash_from_shebang(source).unwrap_or_else(|| {
2581        path.and_then(|path| path.extension().and_then(|ext| ext.to_str()))
2582            .is_some_and(|ext| ext.eq_ignore_ascii_case("bash"))
2583    })
2584}
2585
2586fn infer_bash_from_shebang(source: &str) -> Option<bool> {
2587    shebang_interpreter(source).map(|interpreter| interpreter.eq_ignore_ascii_case("bash"))
2588}
2589
2590fn contains_offset(span: Span, offset: usize) -> bool {
2591    span.start.offset <= offset && offset <= span.end.offset
2592}
2593
2594fn build_references_sorted_by_start(references: &[Reference]) -> Vec<ReferenceId> {
2595    let mut ids: Vec<ReferenceId> = (0..references.len() as u32).map(ReferenceId).collect();
2596    ids.sort_by_key(|id| references[id.index()].span.start.offset);
2597    ids
2598}
2599
2600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2601enum PositionalParameterReferenceKind {
2602    Indexed(usize),
2603    Special,
2604}
2605
2606fn positional_parameter_reference_kind(name: &str) -> Option<PositionalParameterReferenceKind> {
2607    match name {
2608        "@" | "*" | "#" => Some(PositionalParameterReferenceKind::Special),
2609        "0" => None,
2610        _ if name.chars().all(|ch| ch.is_ascii_digit()) => name
2611            .parse::<usize>()
2612            .ok()
2613            .map(PositionalParameterReferenceKind::Indexed),
2614        _ => None,
2615    }
2616}
2617
2618fn reference_has_local_positional_reset(
2619    semantic: &SemanticModel,
2620    scope: ScopeId,
2621    offset: usize,
2622    local_reset_offsets_by_scope: &FxHashMap<ScopeId, Vec<usize>>,
2623) -> bool {
2624    semantic
2625        .transient_ancestor_scopes_within_function(scope)
2626        .any(|transient_scope| {
2627            local_reset_offsets_by_scope
2628                .get(&transient_scope)
2629                .is_some_and(|offsets| offsets.iter().any(|reset_offset| *reset_offset < offset))
2630        })
2631}
2632
2633fn build_bindings_sorted_by_start(bindings: &[Binding]) -> Vec<BindingId> {
2634    let mut ids: Vec<BindingId> = (0..bindings.len() as u32).map(BindingId).collect();
2635    ids.sort_by_key(|id| bindings[id.index()].span.start.offset);
2636    ids
2637}
2638
2639fn build_guarded_or_defaulting_reference_offsets_by_name(
2640    references: &[Reference],
2641    guarded_parameter_refs: &FxHashSet<ReferenceId>,
2642    defaulting_parameter_operand_refs: &FxHashSet<ReferenceId>,
2643) -> FxHashMap<Name, Box<[usize]>> {
2644    let mut offsets_by_name = FxHashMap::<Name, Vec<usize>>::default();
2645
2646    for reference in references {
2647        if guarded_parameter_refs.contains(&reference.id)
2648            || defaulting_parameter_operand_refs.contains(&reference.id)
2649        {
2650            offsets_by_name
2651                .entry(reference.name.clone())
2652                .or_default()
2653                .push(reference.span.start.offset);
2654        }
2655    }
2656
2657    offsets_by_name
2658        .into_iter()
2659        .map(|(name, mut offsets)| {
2660            offsets.sort_unstable();
2661            offsets.dedup();
2662            (name, offsets.into_boxed_slice())
2663        })
2664        .collect()
2665}
2666
2667fn build_declarations_by_command_span(declarations: &[Declaration]) -> FxHashMap<SpanKey, usize> {
2668    let mut index = FxHashMap::with_capacity_and_hasher(declarations.len(), Default::default());
2669    for (declaration_index, declaration) in declarations.iter().enumerate() {
2670        index.insert(SpanKey::new(declaration.span), declaration_index);
2671    }
2672    index
2673}
2674
2675fn build_bindings_by_definition_span(bindings: &[Binding]) -> FxHashMap<SpanKey, BindingId> {
2676    let mut index = FxHashMap::with_capacity_and_hasher(bindings.len(), Default::default());
2677    for binding in bindings {
2678        index.insert(SpanKey::new(binding.span), binding.id);
2679    }
2680    index
2681}
2682
2683/// Iterator returned by [`SemanticModel::references_in_span`].
2684///
2685/// Walks the references sorted index forward from the first candidate and
2686/// stops as soon as a reference starts past the outer span's end.
2687#[derive(Debug, Clone)]
2688pub struct ReferencesInSpan<'a> {
2689    references: &'a [Reference],
2690    ids: std::slice::Iter<'a, ReferenceId>,
2691    end: usize,
2692}
2693
2694impl<'a> Iterator for ReferencesInSpan<'a> {
2695    type Item = &'a Reference;
2696
2697    fn next(&mut self) -> Option<&'a Reference> {
2698        loop {
2699            let id = self.ids.next()?;
2700            let reference = &self.references[id.index()];
2701            if reference.span.start.offset > self.end {
2702                return None;
2703            }
2704            if reference.span.end.offset <= self.end {
2705                return Some(reference);
2706            }
2707        }
2708    }
2709}
2710
2711/// Iterator returned by [`SemanticModel::references_in_command_span`].
2712///
2713/// Walks the direct reference ids recorded for a single command and yields
2714/// only those fully contained within the requested subspan.
2715#[derive(Debug, Clone)]
2716pub struct CommandReferencesInSpan<'a> {
2717    references: &'a [Reference],
2718    ids: std::slice::Iter<'a, ReferenceId>,
2719    outer: Span,
2720}
2721
2722impl<'a> Iterator for CommandReferencesInSpan<'a> {
2723    type Item = &'a Reference;
2724
2725    fn next(&mut self) -> Option<&'a Reference> {
2726        loop {
2727            let id = self.ids.next()?;
2728            let reference = &self.references[id.index()];
2729            if contains_span(self.outer, reference.span) {
2730                return Some(reference);
2731            }
2732        }
2733    }
2734}
2735
2736/// Iterator returned by [`SemanticModel::bindings_in_span`].
2737///
2738/// Walks the bindings sorted index forward from the first candidate and
2739/// stops as soon as a binding starts past the outer span's end.
2740#[derive(Debug, Clone)]
2741pub struct BindingsInSpan<'a> {
2742    bindings: &'a [Binding],
2743    ids: std::slice::Iter<'a, BindingId>,
2744    end: usize,
2745}
2746
2747impl<'a> Iterator for BindingsInSpan<'a> {
2748    type Item = &'a Binding;
2749
2750    fn next(&mut self) -> Option<&'a Binding> {
2751        loop {
2752            let id = self.ids.next()?;
2753            let binding = &self.bindings[id.index()];
2754            if binding.span.start.offset > self.end {
2755                return None;
2756            }
2757            if binding.span.end.offset <= self.end {
2758                return Some(binding);
2759            }
2760        }
2761    }
2762}
2763
2764fn scope_span_width(span: Span) -> usize {
2765    span.end.offset.saturating_sub(span.start.offset)
2766}
2767
2768fn contains_span(outer: Span, inner: Span) -> bool {
2769    outer.start.offset <= inner.start.offset && outer.end.offset >= inner.end.offset
2770}
2771
2772#[cfg(test)]
2773fn linear_scope_at(scopes: &[Scope], offset: usize) -> ScopeId {
2774    scopes
2775        .iter()
2776        .filter(|scope| contains_offset(scope.span, offset))
2777        .min_by_key(|scope| scope_span_width(scope.span))
2778        .map(|scope| scope.id)
2779        .unwrap_or(ScopeId(0))
2780}
2781
2782fn build_indirect_targets_by_binding(
2783    bindings: &[Binding],
2784    indirect_target_hints: &FxHashMap<BindingId, IndirectTargetHint>,
2785) -> FxHashMap<BindingId, Vec<BindingId>> {
2786    let mut targets_by_binding = FxHashMap::default();
2787    for (binding_id, hint) in indirect_target_hints {
2788        let targets: Vec<_> = bindings
2789            .iter()
2790            .filter(|binding| indirect_target_matches(hint, binding))
2791            .map(|binding| binding.id)
2792            .collect();
2793        if !targets.is_empty() {
2794            targets_by_binding.insert(*binding_id, targets);
2795        }
2796    }
2797    targets_by_binding
2798}
2799
2800fn build_indirect_targets_by_reference(
2801    references: &[Reference],
2802    resolved: &FxHashMap<ReferenceId, BindingId>,
2803    indirect_expansion_refs: &FxHashSet<ReferenceId>,
2804    indirect_targets_by_binding: &FxHashMap<BindingId, Vec<BindingId>>,
2805) -> FxHashMap<ReferenceId, Vec<BindingId>> {
2806    let mut targets_by_reference = FxHashMap::default();
2807    for reference in references {
2808        if !indirect_expansion_refs.contains(&reference.id) {
2809            continue;
2810        }
2811        let Some(binding_id) = resolved.get(&reference.id).copied() else {
2812            continue;
2813        };
2814        if let Some(targets) = indirect_targets_by_binding.get(&binding_id) {
2815            targets_by_reference.insert(reference.id, targets.clone());
2816        }
2817    }
2818    targets_by_reference
2819}
2820
2821fn build_array_like_indirect_expansion_refs(
2822    references: &[Reference],
2823    resolved: &FxHashMap<ReferenceId, BindingId>,
2824    indirect_expansion_refs: &FxHashSet<ReferenceId>,
2825    indirect_target_hints: &FxHashMap<BindingId, IndirectTargetHint>,
2826) -> FxHashSet<ReferenceId> {
2827    let mut array_like_refs = FxHashSet::default();
2828    for reference in references {
2829        if !indirect_expansion_refs.contains(&reference.id) {
2830            continue;
2831        }
2832        let Some(binding_id) = resolved.get(&reference.id).copied() else {
2833            continue;
2834        };
2835        let Some(hint) = indirect_target_hints.get(&binding_id) else {
2836            continue;
2837        };
2838        let array_like = match hint {
2839            IndirectTargetHint::Exact { array_like, .. }
2840            | IndirectTargetHint::Pattern { array_like, .. } => *array_like,
2841        };
2842        if array_like {
2843            array_like_refs.insert(reference.id);
2844        }
2845    }
2846    array_like_refs
2847}
2848
2849fn indirect_target_matches(hint: &IndirectTargetHint, binding: &Binding) -> bool {
2850    match hint {
2851        IndirectTargetHint::Exact { name, array_like } => {
2852            binding.name == *name && (!array_like || binding::is_array_like_binding(binding))
2853        }
2854        IndirectTargetHint::Pattern {
2855            prefix,
2856            suffix,
2857            array_like,
2858        } => {
2859            let name = binding.name.as_str();
2860            name.starts_with(prefix.as_str())
2861                && name.ends_with(suffix.as_str())
2862                && (!array_like || binding::is_array_like_binding(binding))
2863        }
2864    }
2865}
2866
2867#[cfg(test)]
2868mod plugins;
2869#[cfg(test)]
2870mod tests;