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