Skip to main content

shuck_linter/
lib.rs

1#![warn(missing_docs)]
2#![cfg_attr(not(test), warn(clippy::unwrap_used))]
3
4//! Linting and fix application for shell scripts parsed by the Shuck toolchain.
5//!
6//! This crate combines parser output, semantic analysis, suppressions, and rule metadata into a
7//! diagnostics pipeline used by `shuck check`.
8mod ambient_contracts;
9#[allow(missing_docs)]
10mod checker;
11#[allow(missing_docs)]
12mod diagnostic;
13#[allow(missing_docs)]
14mod facts;
15#[allow(missing_docs)]
16mod fix;
17#[allow(missing_docs)]
18mod fix_helpers;
19#[allow(missing_docs)]
20mod locator;
21#[allow(missing_docs)]
22mod named_groups;
23#[allow(missing_docs)]
24mod parse_diagnostics;
25#[allow(missing_docs)]
26mod registry;
27#[allow(missing_docs)]
28mod rule_metadata;
29#[allow(missing_docs)]
30mod rule_selector;
31#[allow(missing_docs)]
32mod rule_set;
33#[allow(missing_docs)]
34/// Rule implementations and rule-oriented helper modules.
35#[deny(clippy::wildcard_enum_match_arm)]
36pub mod rules;
37#[allow(missing_docs)]
38mod settings;
39#[allow(missing_docs)]
40mod shell;
41#[allow(missing_docs)]
42mod suppression;
43#[allow(missing_docs)]
44mod violation;
45
46#[cfg(test)]
47/// Test helpers for rule and fix assertions.
48#[allow(missing_docs)]
49pub mod test;
50
51pub use ambient_contracts::{
52    AmbientContractActivation, AmbientContractConfig, AmbientContractEffects, AmbientContractSpec,
53    AmbientFunctionContractSpec, EffectiveAmbientContracts, ResolvedAmbientContracts,
54    ResolvedAmbientRequestContracts,
55};
56/// Primary checker API for walking facts and emitting diagnostics.
57pub use checker::Checker;
58/// Rule diagnostics and severity levels.
59pub use diagnostic::{Diagnostic, Severity};
60/// Command-substitution classification exposed by fact APIs.
61pub use facts::CommandSubstitutionKind;
62pub use facts::words::{
63    ExpansionAnalysis, ExpansionContext, ExpansionHazards, ExpansionValueShape,
64    RuntimeLiteralAnalysis, TestOperandClass, WordClassification, WordExpansionKind,
65    WordFactContext, WordFactHostKind, WordLiteralness, WordOccurrence, WordOccurrenceIter,
66    WordOccurrenceRef, WordQuote, WordSubstitutionShape, leading_literal_word_prefix,
67};
68/// Extracted structural facts available to rules and callers.
69pub use facts::{
70    AmbiguousArrayReference, ArithmeticLiteralFact, ArithmeticLiteralKind, BacktickFragmentFact,
71    CommandFact, CommandFactRef, CommandFacts, ConditionalBareWordFact, ConditionalBinaryFact,
72    ConditionalFact, ConditionalMixedLogicalOperatorFact, ConditionalNodeFact,
73    ConditionalOperandFact, ConditionalOperatorFamily, ConditionalPortabilityFacts,
74    ConditionalUnaryFact, FileDescriptionCommentFact, ForHeaderFact, FunctionCallArityFacts,
75    FunctionHeaderFact, IndexedArrayReferenceFragment, IndexedArrayReferenceFragmentFact,
76    LegacyArithmeticFragmentFact, ListFact, ListOperatorFact, LoopHeaderWordFact,
77    NativeZshScalarArrayReference, PipelineFact, PipelineOperatorFact, PipelineSegmentFact,
78    PlainUnindexedArrayReferenceFact, PositionalParameterFragmentFact, RedirectDevNullStatus,
79    RedirectFact, RedirectTargetAnalysis, RedirectTargetKind, ScriptLineCountFact,
80    SelectHeaderFact, SelectorRequiredArrayReference, SimpleTestFact, SimpleTestOperatorFamily,
81    SimpleTestShape, SimpleTestSyntax, SingleQuotedFragmentFact, StatementFact, SubstitutionFact,
82    SubstitutionHostKind, SubstitutionOutputIntent, SudoFamilyInvoker,
83};
84/// Fact collection types and stable identifiers into those collections.
85pub use facts::{
86    AssignmentFacts, CommandFactQueries, CommandId, CompatFacts, DeclarationKind, FactSpan,
87    LinterFacts, NormalizedCommand, NormalizedDeclaration, SourceFacts, WordFacts, WrapperKind,
88};
89pub(crate) use facts::{
90    ComparableNameKey, ComparableNameUseKind, ComparablePathKey, ComparablePathMatchKey,
91};
92/// Autofix types and fix application helpers.
93pub use fix::{Applicability, AppliedFixes, Edit, Fix, FixAvailability, apply_fixes};
94pub(crate) use fix_helpers::leading_static_word_prefix_fix_in_source;
95pub(crate) use locator::Locator;
96/// Rule selector parsing types.
97pub use named_groups::NamedGroup;
98/// Rule identifiers, categories, and registry lookup helpers.
99pub use registry::{Category, Rule, code_to_rule};
100/// Rule metadata lookup utilities.
101pub use rule_metadata::{RuleMetadata, ShellCheckLevel, rule_metadata, rule_metadata_by_code};
102pub use rule_selector::{RuleSelector, SelectorParseError};
103/// Sets of enabled or disabled rules.
104pub use rule_set::RuleSet;
105#[allow(unused_imports)]
106pub(crate) use rules::common::word::conditional_binary_op_is_string_match;
107/// Linter configuration and per-file ignore types.
108pub use settings::{
109    AmbientShellOptions, C001RuleOptions, C063RuleOptions, C158RuleOptions, C159RuleOptions,
110    C160RuleOptions, C161RuleOptions, CompiledPerFileIgnoreList, LinterRuleOptions, LinterSettings,
111    PerFileIgnore, S078RuleOptions, S079RuleOptions, S080RuleOptions, S081RuleOptions,
112    S082RuleOptions, S083FunctionDocRequirement, S083RuleOptions, S084RuleOptions, S085RuleOptions,
113};
114/// Shell dialect selection used by the linter.
115pub use shell::ShellDialect;
116/// Option-sensitive shell behavior enums reused by linter facts and rules.
117pub use shuck_semantic::{
118    ArithmeticLiteralBehavior, FieldSplittingBehavior, GlobDotBehavior, GlobFailureBehavior,
119    GlobPatternBehavior, PathnameExpansionBehavior, PatternOperatorBehavior,
120    SubscriptIndexBehavior,
121};
122/// Suppression directives, shellcheck mappings, and rewrite helpers.
123pub use suppression::{
124    AddIgnoreParseError, AddIgnoreResult, ShellCheckCodeMap, SuppressionAction,
125    SuppressionDirective, SuppressionIndex, SuppressionSource, add_ignores_to_path,
126    add_ignores_to_path_with_resolvers, build_ignore_edit_for_line, parse_directives,
127};
128/// Trait implemented by rule-specific diagnostic payloads.
129pub use violation::Violation;
130
131use rustc_hash::{FxHashMap, FxHashSet};
132use shuck_ast::{Command, CompoundCommand, File, Span, Stmt, StmtSeq, TextSize};
133use shuck_indexer::Indexer;
134
135use shuck_parser::parser::ParseResult;
136use shuck_semantic::{
137    CommandKind, CompoundCommandKind, FlowContext, PluginResolver, ScopeId, SemanticBuildOptions,
138    SemanticCommandContext, SemanticModel, SourcePathResolver, TraversalObserver,
139    build_with_observer_with_options,
140};
141use std::ops::Deref;
142use std::path::Path;
143
144use crate::suppression::{
145    DirectiveAttachmentFacts, DirectiveCommandVisit, filter_attached_directives,
146    first_statement_line, sort_command_spans_for_lookup, statement_suppression_span,
147};
148
149/// Combined semantic model and diagnostic output for a file analysis pass.
150pub struct AnalysisResult {
151    /// Semantic model built for the analyzed file.
152    pub semantic: SemanticModel,
153    /// Diagnostics emitted by linter rules and parse checks.
154    pub diagnostics: Vec<Diagnostic>,
155}
156
157/// Request object for running linter analysis.
158///
159/// Build one from a [`ParseResult`] when lint output should include parse-aware diagnostics and
160/// inline suppression directives. Build one from a parsed [`File`] only when the caller already
161/// owns parse diagnostics and wants rule diagnostics for that AST.
162pub struct AnalysisRequest<'a> {
163    input: AnalysisInput<'a>,
164    source: &'a str,
165    indexer: Indexer,
166    settings: &'a LinterSettings,
167    source_path: Option<&'a Path>,
168    source_path_resolver: Option<&'a (dyn SourcePathResolver + Send + Sync)>,
169    plugin_resolver: Option<&'a (dyn PluginResolver + Send + Sync)>,
170    suppressions: SuppressionRequest<'a>,
171}
172
173enum AnalysisInput<'a> {
174    File(&'a File),
175    ParseResult(&'a ParseResult),
176}
177
178enum SuppressionRequest<'a> {
179    None,
180    DefaultShellCheckMap,
181    Index(&'a SuppressionIndex),
182    Directives(&'a [SuppressionDirective]),
183    ShellCheckMap(&'a ShellCheckCodeMap),
184}
185
186impl<'a> AnalysisRequest<'a> {
187    /// Creates a request from a parsed shell file.
188    ///
189    /// This form runs rule analysis over the provided AST without parse diagnostics or
190    /// directive-derived suppressions. Use [`AnalysisRequest::from_parse_result`] for the normal
191    /// linting path where parser diagnostics and inline suppressions should be honored.
192    pub fn from_file(file: &'a File, source: &'a str, settings: &'a LinterSettings) -> Self {
193        Self {
194            input: AnalysisInput::File(file),
195            source,
196            indexer: Indexer::for_file(source, file),
197            settings,
198            source_path: None,
199            source_path_resolver: None,
200            plugin_resolver: None,
201            suppressions: SuppressionRequest::None,
202        }
203    }
204
205    /// Creates a request from a parser result.
206    ///
207    /// This form lets [`analyze`] and [`lint`] include parse-aware diagnostics from the supplied
208    /// parser result.
209    pub fn from_parse_result(
210        parse_result: &'a ParseResult,
211        source: &'a str,
212        settings: &'a LinterSettings,
213    ) -> Self {
214        Self {
215            input: AnalysisInput::ParseResult(parse_result),
216            source,
217            indexer: Indexer::new(source, parse_result),
218            settings,
219            source_path: None,
220            source_path_resolver: None,
221            plugin_resolver: None,
222            suppressions: SuppressionRequest::DefaultShellCheckMap,
223        }
224    }
225
226    /// Sets the source path used for shell inference, source resolution, and per-file ignores.
227    pub fn with_source_path(mut self, source_path: &'a Path) -> Self {
228        self.source_path = Some(source_path);
229        self
230    }
231
232    /// Sets an optional source path used for shell inference, source resolution, and per-file ignores.
233    pub fn with_optional_source_path(mut self, source_path: Option<&'a Path>) -> Self {
234        self.source_path = source_path;
235        self
236    }
237
238    /// Sets a resolver for shell sources reached from the analyzed file.
239    pub fn with_source_path_resolver(
240        mut self,
241        source_path_resolver: &'a (dyn SourcePathResolver + Send + Sync),
242    ) -> Self {
243        self.source_path_resolver = Some(source_path_resolver);
244        self
245    }
246
247    /// Sets an optional resolver for shell sources reached from the analyzed file.
248    pub fn with_optional_source_path_resolver(
249        mut self,
250        source_path_resolver: Option<&'a (dyn SourcePathResolver + Send + Sync)>,
251    ) -> Self {
252        self.source_path_resolver = source_path_resolver;
253        self
254    }
255
256    /// Sets a plugin resolver used while building semantic facts.
257    pub fn with_plugin_resolver(
258        mut self,
259        plugin_resolver: &'a (dyn PluginResolver + Send + Sync),
260    ) -> Self {
261        self.plugin_resolver = Some(plugin_resolver);
262        self
263    }
264
265    /// Sets an optional plugin resolver used while building semantic facts.
266    pub fn with_optional_plugin_resolver(
267        mut self,
268        plugin_resolver: Option<&'a (dyn PluginResolver + Send + Sync)>,
269    ) -> Self {
270        self.plugin_resolver = plugin_resolver;
271        self
272    }
273
274    /// Uses an already-built suppression index for the request.
275    pub fn with_suppression_index(mut self, suppression_index: &'a SuppressionIndex) -> Self {
276        self.suppressions = SuppressionRequest::Index(suppression_index);
277        self
278    }
279
280    /// Uses an optional already-built suppression index for the request.
281    pub fn with_optional_suppression_index(
282        mut self,
283        suppression_index: Option<&'a SuppressionIndex>,
284    ) -> Self {
285        self.suppressions = suppression_index
286            .map(SuppressionRequest::Index)
287            .unwrap_or(SuppressionRequest::None);
288        self
289    }
290
291    /// Disables directive-derived and externally supplied suppressions for this request.
292    pub fn without_suppressions(mut self) -> Self {
293        self.suppressions = SuppressionRequest::None;
294        self
295    }
296
297    /// Uses already parsed suppression directives for the request.
298    pub fn with_directives(mut self, directives: &'a [SuppressionDirective]) -> Self {
299        self.suppressions = SuppressionRequest::Directives(directives);
300        self
301    }
302
303    /// Parses suppression directives using the provided compatibility-code map.
304    pub fn with_shellcheck_map(mut self, shellcheck_map: &'a ShellCheckCodeMap) -> Self {
305        self.suppressions = SuppressionRequest::ShellCheckMap(shellcheck_map);
306        self
307    }
308
309    /// Runs semantic analysis and returns both the semantic model and diagnostics.
310    pub fn analyze(self) -> AnalysisResult {
311        analyze(self)
312    }
313
314    /// Runs linting and returns diagnostics.
315    pub fn lint(self) -> Vec<Diagnostic> {
316        lint(self)
317    }
318
319    /// Returns the positional index built for this request's source and AST.
320    pub fn indexer(&self) -> &Indexer {
321        &self.indexer
322    }
323
324    fn file(&self) -> &'a File {
325        match self.input {
326            AnalysisInput::File(file) => file,
327            AnalysisInput::ParseResult(parse_result) => &parse_result.file,
328        }
329    }
330
331    fn parse_result(&self) -> Option<&'a ParseResult> {
332        match self.input {
333            AnalysisInput::File(_) => None,
334            AnalysisInput::ParseResult(parse_result) => Some(parse_result),
335        }
336    }
337}
338
339enum AppliedSuppressionIndex<'a> {
340    None,
341    Borrowed(&'a SuppressionIndex),
342    Owned(SuppressionIndex),
343}
344
345impl AppliedSuppressionIndex<'_> {
346    fn as_ref(&self) -> Option<&SuppressionIndex> {
347        match self {
348            Self::None => None,
349            Self::Borrowed(suppression_index) => Some(suppression_index),
350            Self::Owned(suppression_index) => Some(suppression_index),
351        }
352    }
353}
354
355struct LinterAnalysisResult<'a> {
356    semantic: LinterSemanticArtifacts<'a>,
357    diagnostics: Vec<Diagnostic>,
358    suppression_index: AppliedSuppressionIndex<'a>,
359}
360
361/// Semantic model plus linter-private traversal artifacts needed to build facts.
362pub struct LinterSemanticArtifacts<'a> {
363    semantic: SemanticModel,
364    command_visits_by_id: Vec<Option<facts::CommandVisit<'a>>>,
365    conditional_expression_visits_by_command_span:
366        FxHashMap<facts::FactSpan, Vec<facts::ConditionalExpressionVisit<'a>>>,
367    direct_command_ids_by_body_span: FxHashMap<facts::FactSpan, Vec<CommandId>>,
368    suppression_command_spans: Vec<Span>,
369    directive_attachment_facts: DirectiveAttachmentFacts,
370}
371
372impl<'a> LinterSemanticArtifacts<'a> {
373    /// Builds semantic analysis artifacts for linter fact construction.
374    pub fn build(file: &'a File, source: &'a str, indexer: &Indexer) -> Self {
375        Self::build_with_options(file, source, indexer, SemanticBuildOptions::default())
376    }
377
378    /// Builds semantic analysis artifacts for linter fact construction with custom options.
379    pub fn build_with_options(
380        file: &'a File,
381        source: &'a str,
382        indexer: &Indexer,
383        options: SemanticBuildOptions<'_>,
384    ) -> Self {
385        let mut observer = LintTraversalObserver::default();
386        let semantic =
387            build_with_observer_with_options(file, source, indexer, &mut observer, options);
388        let mut suppression_command_spans = observer.collect_suppression_command_spans(&semantic);
389        sort_command_spans_for_lookup(&mut suppression_command_spans);
390        let directive_attachment_facts = DirectiveAttachmentFacts::from_command_visits(
391            source,
392            indexer.comment_index(),
393            observer.command_visits_by_id.iter().filter_map(|visit| {
394                visit.map(|visit| DirectiveCommandVisit {
395                    stmt: visit.stmt,
396                    command: visit.command,
397                })
398            }),
399        );
400        Self {
401            semantic,
402            command_visits_by_id: observer.command_visits_by_id,
403            conditional_expression_visits_by_command_span: observer
404                .conditional_expression_visits_by_command_span,
405            direct_command_ids_by_body_span: observer.direct_command_ids_by_body_span,
406            suppression_command_spans,
407            directive_attachment_facts,
408        }
409    }
410
411    /// Returns the semantic model built for this file.
412    pub fn semantic(&self) -> &SemanticModel {
413        &self.semantic
414    }
415
416    /// Converts this linter semantic model into the underlying semantic model.
417    pub fn into_semantic(self) -> SemanticModel {
418        self.semantic
419    }
420
421    pub(crate) fn command_visits_by_id(&self) -> &[Option<facts::CommandVisit<'a>>] {
422        &self.command_visits_by_id
423    }
424
425    /// Returns command-shape queries backed by the semantic traversal.
426    ///
427    /// Fact builders should ask this layer for command bodies, descendants, and
428    /// shape-controlled iteration instead of adding new recursive command walks over the parser
429    /// AST. Local word/expression scans still live near their rules, but command topology should
430    /// flow through semantic ids.
431    pub(crate) fn command_topology(&self) -> CommandTopology<'a, '_> {
432        CommandTopology { artifacts: self }
433    }
434
435    pub(crate) fn conditional_expression_visits(
436        &self,
437        command_span: Span,
438    ) -> &[facts::ConditionalExpressionVisit<'a>] {
439        self.conditional_expression_visits_by_command_span
440            .get(&facts::FactSpan::new(command_span))
441            .map_or(&[], Vec::as_slice)
442    }
443
444    pub(crate) fn suppression_command_spans(&self) -> &[Span] {
445        &self.suppression_command_spans
446    }
447
448    pub(crate) fn directive_attachment_facts(&self) -> &DirectiveAttachmentFacts {
449        &self.directive_attachment_facts
450    }
451
452    pub(crate) fn missing_done_trailing_loop_is_for(
453        &self,
454        body: &StmtSeq,
455        eof_offset: usize,
456    ) -> Option<bool> {
457        let mut trailing_loop_kind = None;
458        self.for_each_command_visit_in_body(body, false, |visit| {
459            if visit.stmt.span.end.offset != eof_offset {
460                return;
461            }
462
463            let is_for_loop = match visit.command {
464                Command::Compound(CompoundCommand::For(_)) => true,
465                Command::Compound(CompoundCommand::While(_) | CompoundCommand::Until(_)) => false,
466                _ => return,
467            };
468
469            let start_offset = visit.stmt.span.start.offset;
470            if trailing_loop_kind
471                .as_ref()
472                .is_none_or(|(best_start, _)| start_offset >= *best_start)
473            {
474                trailing_loop_kind = Some((start_offset, is_for_loop));
475            }
476        });
477
478        trailing_loop_kind.map(|(_, is_for_loop)| is_for_loop)
479    }
480
481    #[cfg(test)]
482    pub(crate) fn command_visits_in_body(
483        &self,
484        body: &StmtSeq,
485        descend_nested_word_commands: bool,
486    ) -> Vec<facts::CommandVisit<'a>> {
487        let mut visits = Vec::new();
488        self.for_each_command_visit_in_body(body, descend_nested_word_commands, |visit| {
489            visits.push(visit);
490        });
491        visits
492    }
493
494    pub(crate) fn for_each_command_visit_in_body(
495        &self,
496        body: &StmtSeq,
497        descend_nested_word_commands: bool,
498        mut visitor: impl FnMut(facts::CommandVisit<'a>),
499    ) {
500        self.command_topology().for_each_command_visit_in_body(
501            body,
502            descend_nested_word_commands,
503            |_, visit| {
504                visitor(visit);
505                CommandTopologyTraversal::Descend
506            },
507        );
508    }
509}
510
511/// Traversal control returned by semantic command-topology visitors.
512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
513pub(crate) enum CommandTopologyTraversal {
514    /// Visit children after the current command.
515    Descend,
516    /// Keep the current command but skip its descendants.
517    SkipChildren,
518    /// Stop the traversal immediately.
519    Break,
520}
521
522/// Semantic-backed command-shape queries for linter fact construction.
523///
524/// The semantic pass already records syntax-backed command ids, direct commands for each
525/// statement-sequence body, and parent/child relationships. This wrapper keeps those indexes as
526/// the single source of truth for command topology so facts can migrate away from ad-hoc recursive
527/// AST walks without flattening away local structure like body boundaries, child order, or nested
528/// command-substitution depth.
529#[derive(Clone, Copy)]
530pub(crate) struct CommandTopology<'a, 'artifacts> {
531    artifacts: &'artifacts LinterSemanticArtifacts<'a>,
532}
533
534impl<'a, 'artifacts> CommandTopology<'a, 'artifacts> {
535    /// Returns semantic command topology scoped to a statement-sequence body.
536    ///
537    /// Use this when a fact needs command ids or semantic traversal relationships for commands
538    /// that appear under `body`. For purely syntactic sibling windows, prefer the fact-layer
539    /// `BodyTopology` helper instead.
540    pub(crate) fn body(&self, body: &StmtSeq) -> CommandBodyTopology<'a, 'artifacts> {
541        CommandBodyTopology {
542            topology: *self,
543            direct_ids: self.direct_command_ids_in_body(body),
544        }
545    }
546
547    /// Returns direct semantic command ids recorded for a statement-sequence body.
548    pub(crate) fn direct_command_ids_in_body(&self, body: &StmtSeq) -> &'artifacts [CommandId] {
549        let body_span = facts::FactSpan::new(body.span);
550        self.artifacts
551            .direct_command_ids_by_body_span
552            .get(&body_span)
553            .map_or(&[], Vec::as_slice)
554    }
555
556    /// Returns the parser-backed command visit for `id`, if that semantic command has one.
557    pub(crate) fn command_visit(&self, id: CommandId) -> Option<facts::CommandVisit<'a>> {
558        self.artifacts
559            .command_visits_by_id
560            .get(id.index())
561            .and_then(|visit| *visit)
562    }
563
564    /// Returns the semantic context recorded for `id`.
565    pub(crate) fn command_context(&self, id: CommandId) -> Option<&SemanticCommandContext> {
566        self.artifacts.semantic.command_context(id)
567    }
568
569    /// Returns the structural semantic parent for `id`, if one exists.
570    #[allow(dead_code)]
571    pub(crate) fn command_parent_id(&self, id: CommandId) -> Option<CommandId> {
572        self.artifacts.semantic.command_parent_id(id)
573    }
574
575    /// Returns structural semantic child commands nested under `id`.
576    #[allow(dead_code)]
577    pub(crate) fn command_children(&self, id: CommandId) -> &[CommandId] {
578        self.artifacts.semantic.command_children(id)
579    }
580
581    /// Returns the syntax-backed semantic parent for `id`, if one exists.
582    #[allow(dead_code)]
583    pub(crate) fn syntax_backed_command_parent_id(&self, id: CommandId) -> Option<CommandId> {
584        self.artifacts.semantic.syntax_backed_command_parent_id(id)
585    }
586
587    /// Returns syntax-backed semantic child commands nested directly under `id`.
588    pub(crate) fn syntax_backed_command_children(&self, id: CommandId) -> &[CommandId] {
589        self.artifacts.semantic.syntax_backed_command_children(id)
590    }
591
592    /// Returns the nested word-command depth recorded for `id`.
593    pub(crate) fn nested_word_command_depth(&self, id: CommandId) -> Option<usize> {
594        self.command_context(id)
595            .map(SemanticCommandContext::nested_word_command_depth)
596    }
597
598    /// Iterates command visits inside `body` in semantic source order.
599    ///
600    /// `descend_nested_word_commands` controls whether command substitutions nested inside words
601    /// below this body should be included. The visitor receives the semantic id and parser-backed
602    /// visit, then chooses whether this particular command's children should be traversed.
603    pub(crate) fn for_each_command_visit_in_body(
604        &self,
605        body: &StmtSeq,
606        descend_nested_word_commands: bool,
607        mut visitor: impl FnMut(CommandId, facts::CommandVisit<'a>) -> CommandTopologyTraversal,
608    ) {
609        let root_ids = self.direct_command_ids_in_body(body);
610        self.for_each_command_visit_from_roots(
611            root_ids,
612            descend_nested_word_commands,
613            &mut visitor,
614        );
615    }
616
617    fn for_each_command_visit_from_roots(
618        &self,
619        root_ids: &[CommandId],
620        descend_nested_word_commands: bool,
621        visitor: &mut impl FnMut(CommandId, facts::CommandVisit<'a>) -> CommandTopologyTraversal,
622    ) {
623        if root_ids.is_empty() {
624            return;
625        }
626        let baseline_depth = root_ids
627            .iter()
628            .filter_map(|id| self.nested_word_command_depth(*id))
629            .min()
630            .unwrap_or(0);
631        let mut stack = root_ids.iter().rev().copied().collect::<Vec<_>>();
632        while let Some(id) = stack.pop() {
633            let Some(context) = self.command_context(id) else {
634                continue;
635            };
636            if !descend_nested_word_commands && context.nested_word_command_depth() > baseline_depth
637            {
638                continue;
639            }
640            if let Some(visit) = self.command_visit(id) {
641                match visitor(id, visit) {
642                    CommandTopologyTraversal::Descend => {}
643                    CommandTopologyTraversal::SkipChildren => continue,
644                    CommandTopologyTraversal::Break => break,
645                }
646            }
647            for child in self.syntax_backed_command_children(id).iter().rev() {
648                stack.push(*child);
649            }
650        }
651    }
652}
653
654/// Parser-backed visit paired with its semantic command id.
655#[allow(dead_code)]
656#[derive(Debug, Clone, Copy)]
657pub(crate) struct BodyCommandVisit<'a> {
658    /// Semantic command id for this visit.
659    pub(crate) id: CommandId,
660    /// Parser-backed command visit recorded during semantic traversal.
661    pub(crate) visit: facts::CommandVisit<'a>,
662}
663
664/// Semantic-backed command topology scoped to one statement-sequence body.
665///
666/// This is the command-id counterpart to the fact-layer `BodyTopology`: it preserves body
667/// boundaries while still returning semantic ids, parser-backed visits, and controlled descendant
668/// traversal.
669#[derive(Clone, Copy)]
670pub(crate) struct CommandBodyTopology<'a, 'artifacts> {
671    topology: CommandTopology<'a, 'artifacts>,
672    direct_ids: &'artifacts [CommandId],
673}
674
675impl<'a, 'artifacts> CommandBodyTopology<'a, 'artifacts> {
676    /// Returns direct semantic command ids recorded for this body.
677    #[allow(dead_code)]
678    pub(crate) fn direct_command_ids(&self) -> &'artifacts [CommandId] {
679        self.direct_ids
680    }
681
682    /// Iterates direct command visits recorded for this body.
683    #[allow(dead_code)]
684    pub(crate) fn direct_command_visits(
685        &self,
686    ) -> impl Iterator<Item = BodyCommandVisit<'a>> + use<'a, 'artifacts, '_> {
687        self.direct_ids.iter().filter_map(|id| {
688            self.topology
689                .command_visit(*id)
690                .map(|visit| BodyCommandVisit { id: *id, visit })
691        })
692    }
693
694    /// Iterates adjacent direct command visits recorded for this body.
695    #[allow(dead_code)]
696    pub(crate) fn sibling_command_visit_pairs(
697        &self,
698    ) -> impl Iterator<Item = (BodyCommandVisit<'a>, BodyCommandVisit<'a>)> + use<'a, 'artifacts, '_>
699    {
700        self.direct_ids.windows(2).filter_map(|ids| {
701            let previous_id = ids[0];
702            let current_id = ids[1];
703            Some((
704                BodyCommandVisit {
705                    id: previous_id,
706                    visit: self.topology.command_visit(previous_id)?,
707                },
708                BodyCommandVisit {
709                    id: current_id,
710                    visit: self.topology.command_visit(current_id)?,
711                },
712            ))
713        })
714    }
715
716    /// Iterates command visits below this body in semantic source order.
717    pub(crate) fn for_each_command_visit(
718        &self,
719        descend_nested_word_commands: bool,
720        mut visitor: impl FnMut(CommandId, facts::CommandVisit<'a>) -> CommandTopologyTraversal,
721    ) {
722        self.topology.for_each_command_visit_from_roots(
723            self.direct_ids,
724            descend_nested_word_commands,
725            &mut visitor,
726        );
727    }
728}
729
730impl Deref for LinterSemanticArtifacts<'_> {
731    type Target = SemanticModel;
732
733    fn deref(&self) -> &Self::Target {
734        self.semantic()
735    }
736}
737
738#[derive(Default)]
739struct LintTraversalObserver<'a> {
740    command_visits_by_id: Vec<Option<facts::CommandVisit<'a>>>,
741    conditional_expression_visits_by_command_span:
742        FxHashMap<facts::FactSpan, Vec<facts::ConditionalExpressionVisit<'a>>>,
743    direct_command_ids_by_body_span: FxHashMap<facts::FactSpan, Vec<CommandId>>,
744    suppression_command_spans_by_id: Vec<Option<Span>>,
745}
746
747impl LintTraversalObserver<'_> {
748    fn collect_suppression_command_spans(&self, semantic: &SemanticModel) -> Vec<Span> {
749        let mut spans = Vec::new();
750        for id in semantic.commands().iter().copied() {
751            let Some(Some(span)) = self.suppression_command_spans_by_id.get(id.index()) else {
752                continue;
753            };
754            if suppression_span_is_function_body_wrapper(semantic, id) {
755                continue;
756            }
757            spans.push(*span);
758        }
759        spans
760    }
761}
762
763impl<'a> TraversalObserver<'a> for LintTraversalObserver<'a> {
764    fn conditional_expression(
765        &mut self,
766        command_span: Span,
767        expression: &'a shuck_ast::ConditionalExpr,
768        parent_in_same_logical_group: bool,
769    ) {
770        self.conditional_expression_visits_by_command_span
771            .entry(facts::FactSpan::new(command_span))
772            .or_default()
773            .push(facts::ConditionalExpressionVisit::new(
774                expression,
775                parent_in_same_logical_group,
776            ));
777    }
778
779    fn recorded_command(
780        &mut self,
781        id: CommandId,
782        stmt: &'a Stmt,
783        _scope: ScopeId,
784        _flow: FlowContext,
785    ) {
786        if self.command_visits_by_id.len() <= id.index() {
787            self.command_visits_by_id.resize(id.index() + 1, None);
788        }
789        self.command_visits_by_id[id.index()] = Some(facts::CommandVisit::new(stmt));
790        if self.suppression_command_spans_by_id.len() <= id.index() {
791            self.suppression_command_spans_by_id
792                .resize(id.index() + 1, None);
793        }
794        let span = statement_suppression_span(stmt);
795        if span.start.line != 0 && span.end.line != 0 {
796            self.suppression_command_spans_by_id[id.index()] = Some(span);
797        }
798    }
799
800    fn recorded_statement_sequence_command(
801        &mut self,
802        body_span: Span,
803        _stmt_span: Span,
804        id: CommandId,
805    ) {
806        self.direct_command_ids_by_body_span
807            .entry(facts::FactSpan::new(body_span))
808            .or_default()
809            .push(id);
810    }
811}
812
813fn suppression_span_is_function_body_wrapper(semantic: &SemanticModel, id: CommandId) -> bool {
814    matches!(
815        semantic.command_kind(id),
816        CommandKind::Compound(CompoundCommandKind::BraceGroup)
817            | CommandKind::Compound(CompoundCommandKind::Subshell)
818    ) && semantic.command_parent_id(id).is_some_and(|parent| {
819        matches!(
820            semantic.command_kind(parent),
821            CommandKind::Function | CommandKind::AnonymousFunction
822        )
823    })
824}
825
826fn build_suppression_index_from_semantic(
827    directives: &[SuppressionDirective],
828    file: &File,
829    source: &str,
830    semantic: &LinterSemanticArtifacts<'_>,
831) -> Option<SuppressionIndex> {
832    if directives.is_empty() {
833        return None;
834    }
835
836    let directives =
837        filter_attached_directives(source, directives, semantic.directive_attachment_facts());
838    if directives.is_empty() {
839        return None;
840    }
841
842    Some(SuppressionIndex::from_sorted_command_spans(
843        &directives,
844        semantic.suppression_command_spans(),
845        first_statement_line(file).unwrap_or(u32::MAX),
846    ))
847}
848
849#[cfg(feature = "benchmarking")]
850#[doc(hidden)]
851pub use facts::benchmark::CasePatternMatcher as BenchmarkCasePatternMatcher;
852
853/// Builds semantic facts and linter diagnostics for a request.
854pub fn analyze(request: AnalysisRequest<'_>) -> AnalysisResult {
855    let shell = resolve_shell(request.settings, request.source, request.source_path);
856    let first_parse_error = request.parse_result().and_then(parse_error_position);
857    let mut result = analyze_linter(&request, shell, first_parse_error);
858
859    if let Some(parse_result) = request.parse_result() {
860        add_parse_diagnostics(&mut result, &request, parse_result, shell);
861    }
862    finalize_diagnostics(&mut result, &request);
863
864    AnalysisResult {
865        semantic: result.semantic.into_semantic(),
866        diagnostics: result.diagnostics,
867    }
868}
869
870/// Lints a request and returns diagnostics.
871pub fn lint(request: AnalysisRequest<'_>) -> Vec<Diagnostic> {
872    let shell = resolve_shell(request.settings, request.source, request.source_path);
873    if let Some(parse_result) = request.parse_result() {
874        let mut result = analyze_linter(&request, shell, parse_error_position(parse_result));
875        add_parse_diagnostics(&mut result, &request, parse_result, shell);
876        finalize_diagnostics(&mut result, &request);
877        return result.diagnostics;
878    }
879
880    let mut result = analyze_linter(&request, shell, None);
881    finalize_diagnostics(&mut result, &request);
882    result.diagnostics
883}
884
885fn analyze_linter<'a>(
886    request: &AnalysisRequest<'a>,
887    shell: ShellDialect,
888    first_parse_error: Option<(usize, usize)>,
889) -> LinterAnalysisResult<'a> {
890    let linter_semantic_artifacts = build_linter_semantic_artifacts(request, shell);
891    let suppression_index = build_request_suppression_index(request, &linter_semantic_artifacts);
892    let checker = Checker::new(
893        request.file(),
894        request.source,
895        &linter_semantic_artifacts,
896        request.indexer(),
897        &request.settings.rules,
898        shell,
899        request.settings.ambient_shell_options,
900        request.settings.report_environment_style_names,
901        request.settings.rule_options.clone(),
902        suppression_index.as_ref(),
903        first_parse_error,
904    );
905    let diagnostics = checker.check();
906
907    LinterAnalysisResult {
908        semantic: linter_semantic_artifacts,
909        diagnostics,
910        suppression_index,
911    }
912}
913
914fn build_linter_semantic_artifacts<'a>(
915    request: &AnalysisRequest<'a>,
916    shell: ShellDialect,
917) -> LinterSemanticArtifacts<'a> {
918    let mut file_entry_contract_collector =
919        request
920            .settings
921            .ambient_contracts
922            .collector(request.source, request.source_path, shell);
923    let file_entry_contract_collector_factory =
924        request.settings.ambient_contracts.collector_factory();
925    let analyzed_paths_fallback = request
926        .source_path
927        .map(|path| FxHashSet::from_iter([path.to_path_buf()]));
928    let analyzed_paths = request
929        .settings
930        .analyzed_paths
931        .as_deref()
932        .or(analyzed_paths_fallback.as_ref());
933
934    let shell_profile = shell.shell_profile();
935    LinterSemanticArtifacts::build_with_options(
936        request.file(),
937        request.source,
938        request.indexer(),
939        SemanticBuildOptions {
940            source_path: request.source_path,
941            source_path_resolver: request.source_path_resolver,
942            plugin_resolver: request.plugin_resolver,
943            file_entry_contract: None,
944            file_entry_contract_collector: Some(&mut file_entry_contract_collector),
945            file_entry_contract_collector_factory: Some(&file_entry_contract_collector_factory),
946            analyzed_paths,
947            shell_profile: Some(shell_profile),
948            resolve_source_closure: request.settings.resolve_source_closure,
949        },
950    )
951}
952
953fn build_request_suppression_index<'a>(
954    request: &AnalysisRequest<'a>,
955    semantic: &LinterSemanticArtifacts<'_>,
956) -> AppliedSuppressionIndex<'a> {
957    match request.suppressions {
958        SuppressionRequest::None => AppliedSuppressionIndex::None,
959        SuppressionRequest::DefaultShellCheckMap => {
960            let shellcheck_map = ShellCheckCodeMap::default();
961            let directives = parse_directives(
962                request.source,
963                request.indexer().comment_index(),
964                &shellcheck_map,
965            );
966            build_suppression_index_from_semantic(
967                &directives,
968                request.file(),
969                request.source,
970                semantic,
971            )
972            .map(AppliedSuppressionIndex::Owned)
973            .unwrap_or(AppliedSuppressionIndex::None)
974        }
975        SuppressionRequest::Index(suppression_index) => {
976            AppliedSuppressionIndex::Borrowed(suppression_index)
977        }
978        SuppressionRequest::Directives(directives) => build_suppression_index_from_semantic(
979            directives,
980            request.file(),
981            request.source,
982            semantic,
983        )
984        .map(AppliedSuppressionIndex::Owned)
985        .unwrap_or(AppliedSuppressionIndex::None),
986        SuppressionRequest::ShellCheckMap(shellcheck_map) => {
987            let directives = parse_directives(
988                request.source,
989                request.indexer().comment_index(),
990                shellcheck_map,
991            );
992            build_suppression_index_from_semantic(
993                &directives,
994                request.file(),
995                request.source,
996                semantic,
997            )
998            .map(AppliedSuppressionIndex::Owned)
999            .unwrap_or(AppliedSuppressionIndex::None)
1000        }
1001    }
1002}
1003
1004fn add_parse_diagnostics(
1005    result: &mut LinterAnalysisResult<'_>,
1006    request: &AnalysisRequest<'_>,
1007    parse_result: &ParseResult,
1008    shell: ShellDialect,
1009) {
1010    let locator = Locator::new(request.source, request.indexer().line_index());
1011    let parse_diagnostics = parse_diagnostics::collect_parse_rule_diagnostics(
1012        request.file(),
1013        locator,
1014        Some(parse_result),
1015        &result.semantic,
1016        &request.settings.rules,
1017        shell,
1018    );
1019    result.diagnostics.extend(parse_diagnostics);
1020    if parse_result.is_err() {
1021        sanitize_diagnostic_spans_cold(&mut result.diagnostics, locator);
1022    }
1023}
1024
1025fn finalize_diagnostics(result: &mut LinterAnalysisResult<'_>, request: &AnalysisRequest<'_>) {
1026    for diagnostic in result.diagnostics.iter_mut() {
1027        if let Some(&severity) = request.settings.severity_overrides.get(&diagnostic.rule) {
1028            diagnostic.severity = severity;
1029        }
1030    }
1031
1032    if let Some(suppression_index) = result.suppression_index.as_ref() {
1033        filter_suppressed_diagnostics(
1034            &mut result.diagnostics,
1035            request.indexer(),
1036            suppression_index,
1037        );
1038    }
1039    filter_per_file_ignored_diagnostics(
1040        &mut result.diagnostics,
1041        request.settings,
1042        request.source_path,
1043    );
1044
1045    result
1046        .diagnostics
1047        .sort_by_key(|diagnostic| (diagnostic.span.start.offset, diagnostic.span.end.offset));
1048}
1049
1050fn resolve_shell(
1051    settings: &LinterSettings,
1052    source: &str,
1053    source_path: Option<&Path>,
1054) -> ShellDialect {
1055    if settings.shell == ShellDialect::Unknown {
1056        ShellDialect::infer(source, source_path)
1057    } else {
1058        settings.shell
1059    }
1060}
1061
1062fn parse_error_position(parse_result: &ParseResult) -> Option<(usize, usize)> {
1063    if !parse_result.is_err() {
1064        return None;
1065    }
1066
1067    let shuck_parser::Error::Parse { line, column, .. } = parse_result.strict_error();
1068    if line > 0 && column > 0 {
1069        return Some((line, column));
1070    }
1071
1072    parse_result
1073        .diagnostics
1074        .first()
1075        .map(|diagnostic| (diagnostic.span.start.line, diagnostic.span.start.column))
1076}
1077
1078fn filter_suppressed_diagnostics(
1079    diagnostics: &mut Vec<Diagnostic>,
1080    indexer: &Indexer,
1081    suppression_index: &SuppressionIndex,
1082) {
1083    diagnostics.retain(|diagnostic| {
1084        let line = indexer
1085            .line_index()
1086            .line_number(TextSize::new(diagnostic.span.start.offset as u32));
1087        let Ok(line) = u32::try_from(line) else {
1088            return true;
1089        };
1090
1091        !suppression_index.is_suppressed(diagnostic.rule, line)
1092    });
1093}
1094
1095fn filter_per_file_ignored_diagnostics(
1096    diagnostics: &mut Vec<Diagnostic>,
1097    settings: &LinterSettings,
1098    source_path: Option<&Path>,
1099) {
1100    let ignored_rules = settings.per_file_ignored_rules(source_path);
1101    if ignored_rules.is_empty() {
1102        return;
1103    }
1104
1105    diagnostics.retain(|diagnostic| !ignored_rules.contains(diagnostic.rule));
1106}
1107
1108#[cold]
1109#[inline(never)]
1110fn sanitize_diagnostic_spans_cold(diagnostics: &mut [Diagnostic], locator: Locator<'_>) {
1111    for diagnostic in diagnostics {
1112        diagnostic.span = sanitize_span(diagnostic.span, locator);
1113    }
1114}
1115
1116#[cold]
1117fn sanitize_span(span: Span, locator: Locator<'_>) -> Span {
1118    let source = locator.source();
1119    if span.start.offset <= span.end.offset
1120        && span.end.offset <= source.len()
1121        && source.is_char_boundary(span.start.offset)
1122        && source.is_char_boundary(span.end.offset)
1123    {
1124        return span;
1125    }
1126
1127    let offsets_are_bounded = span.start.offset <= source.len() && span.end.offset <= source.len();
1128    let offsets_are_aligned =
1129        source.is_char_boundary(span.start.offset) && source.is_char_boundary(span.end.offset);
1130    if offsets_are_bounded && offsets_are_aligned && span.start.offset > span.end.offset {
1131        return Span::from_positions(span.end, span.start);
1132    }
1133
1134    let len = source.len();
1135    let raw_start = span.start.offset.min(len);
1136    let raw_end = span.end.offset.min(len);
1137    let (start_offset, end_offset) = if raw_start <= raw_end {
1138        (
1139            floor_char_boundary(source, raw_start),
1140            ceil_char_boundary(source, raw_end),
1141        )
1142    } else {
1143        (
1144            floor_char_boundary(source, raw_end),
1145            ceil_char_boundary(source, raw_start),
1146        )
1147    };
1148
1149    Span::from_positions(
1150        locator.position_at_offset_unchecked(start_offset),
1151        locator.position_at_offset_unchecked(end_offset),
1152    )
1153}
1154
1155#[cold]
1156fn floor_char_boundary(source: &str, offset: usize) -> usize {
1157    let mut offset = offset.min(source.len());
1158    while offset > 0 && !source.is_char_boundary(offset) {
1159        offset -= 1;
1160    }
1161    offset
1162}
1163
1164#[cold]
1165fn ceil_char_boundary(source: &str, offset: usize) -> usize {
1166    let mut offset = offset.min(source.len());
1167    while offset < source.len() && !source.is_char_boundary(offset) {
1168        offset += 1;
1169    }
1170    offset
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175    use super::*;
1176    use shuck_ast::{Command, Position, Span, StmtSeq, WordPart, WordPartNode};
1177    use shuck_parser::Error as ParseError;
1178    use shuck_parser::parser::{
1179        ParseDiagnostic, ParseStatus, Parser, ShellDialect as ParseDialect, SyntaxFacts,
1180    };
1181    use shuck_semantic::{
1182        FileContract, PluginFramework, PluginRequest, PluginRequestKind, PluginResolution,
1183    };
1184    use std::fs;
1185    use std::path::{Path, PathBuf};
1186    use tempfile::tempdir;
1187
1188    fn lint(source: &str, settings: &LinterSettings) -> Vec<Diagnostic> {
1189        let output = Parser::new(source).parse().unwrap();
1190        let indexer = Indexer::new(source, &output);
1191        let directives = parse_directives(
1192            source,
1193            indexer.comment_index(),
1194            &ShellCheckCodeMap::default(),
1195        );
1196        AnalysisRequest::from_parse_result(&output, source, settings)
1197            .with_directives(&directives)
1198            .lint()
1199    }
1200
1201    fn lint_path(path: &Path, settings: &LinterSettings) -> Vec<Diagnostic> {
1202        let source = fs::read_to_string(path).unwrap();
1203        let output = Parser::new(&source).parse().unwrap();
1204        let indexer = Indexer::new(&source, &output);
1205        let directives = parse_directives(
1206            &source,
1207            indexer.comment_index(),
1208            &ShellCheckCodeMap::default(),
1209        );
1210        AnalysisRequest::from_parse_result(&output, &source, settings)
1211            .with_source_path(path)
1212            .with_directives(&directives)
1213            .lint()
1214    }
1215
1216    fn lint_for_rule(source: &str, rule: Rule) -> Vec<Diagnostic> {
1217        lint(source, &LinterSettings::for_rule(rule))
1218    }
1219
1220    fn lint_path_for_rule(path: &Path, rule: Rule) -> Vec<Diagnostic> {
1221        lint_path(path, &LinterSettings::for_rule(rule))
1222    }
1223
1224    fn lint_path_for_rule_with_resolver(
1225        path: &Path,
1226        rule: Rule,
1227        source_path_resolver: Option<&(dyn SourcePathResolver + Send + Sync)>,
1228    ) -> Vec<Diagnostic> {
1229        let source = fs::read_to_string(path).unwrap();
1230        let output = Parser::new(&source).parse().unwrap();
1231        let indexer = Indexer::new(&source, &output);
1232        let directives = parse_directives(
1233            &source,
1234            indexer.comment_index(),
1235            &ShellCheckCodeMap::default(),
1236        );
1237        let settings = LinterSettings::for_rule(rule);
1238        AnalysisRequest::from_parse_result(&output, &source, &settings)
1239            .with_source_path(path)
1240            .with_directives(&directives)
1241            .with_optional_source_path_resolver(source_path_resolver)
1242            .lint()
1243    }
1244
1245    fn lint_path_for_rule_with_plugin_resolver(
1246        path: &Path,
1247        rule: Rule,
1248        plugin_resolver: &(dyn PluginResolver + Send + Sync),
1249    ) -> Vec<Diagnostic> {
1250        let source = fs::read_to_string(path).unwrap();
1251        let output = Parser::with_dialect(&source, ParseDialect::Zsh)
1252            .parse()
1253            .unwrap();
1254        let indexer = Indexer::new(&source, &output);
1255        let directives = parse_directives(
1256            &source,
1257            indexer.comment_index(),
1258            &ShellCheckCodeMap::default(),
1259        );
1260        let settings = LinterSettings::for_rule(rule);
1261        AnalysisRequest::from_parse_result(&output, &source, &settings)
1262            .with_source_path(path)
1263            .with_directives(&directives)
1264            .with_plugin_resolver(plugin_resolver)
1265            .lint()
1266    }
1267
1268    fn lint_named_source(path: &Path, source: &str, settings: &LinterSettings) -> Vec<Diagnostic> {
1269        let output = Parser::new(source).parse().unwrap();
1270        let indexer = Indexer::new(source, &output);
1271        let directives = parse_directives(
1272            source,
1273            indexer.comment_index(),
1274            &ShellCheckCodeMap::default(),
1275        );
1276        AnalysisRequest::from_parse_result(&output, source, settings)
1277            .with_source_path(path)
1278            .with_directives(&directives)
1279            .lint()
1280    }
1281
1282    fn lint_named_source_with_parse_dialect(
1283        path: &Path,
1284        source: &str,
1285        parse_dialect: ParseDialect,
1286        settings: &LinterSettings,
1287    ) -> Vec<Diagnostic> {
1288        let output = Parser::with_dialect(source, parse_dialect).parse().unwrap();
1289        let indexer = Indexer::new(source, &output);
1290        let directives = parse_directives(
1291            source,
1292            indexer.comment_index(),
1293            &ShellCheckCodeMap::default(),
1294        );
1295        AnalysisRequest::from_parse_result(&output, source, settings)
1296            .with_source_path(path)
1297            .with_directives(&directives)
1298            .lint()
1299    }
1300
1301    fn runtime_prelude_source(shebang: &str) -> String {
1302        format!(
1303            "{shebang}\nprintf '%s\\n' \"$IFS\" \"$USER\" \"$HOME\" \"$SHELL\" \"$PWD\" \"$TERM\" \"$PATH\" \"$CDPATH\" \"$LANG\" \"$LC_ALL\" \"$LC_TIME\" \"$SUDO_USER\" \"$DOAS_USER\"\nprintf '%s\\n' \"$LINENO\" \"$FUNCNAME\" \"${{BASH_SOURCE[0]}}\" \"${{BASH_LINENO[0]}}\" \"$RANDOM\" \"${{BASH_REMATCH[0]}}\" \"$READLINE_LINE\" \"$BASH_VERSION\" \"${{BASH_VERSINFO[0]}}\" \"$OSTYPE\" \"$HISTCONTROL\" \"$HISTSIZE\"\n"
1304        )
1305    }
1306
1307    fn zsh_runtime_prelude_source(shebang: &str) -> String {
1308        format!(
1309            "{shebang}\nprintf '%s\\n' \"${{options[xtrace]}}\" \"${{functions[typeset]}}\" \"${{path[1]}}\" \"${{pipestatus[1]}}\" \"$MATCH\" \"$ZSH_VERSION\"\n"
1310        )
1311    }
1312
1313    fn first_command_substitution_body(parts: &[WordPartNode]) -> Option<&StmtSeq> {
1314        for part in parts {
1315            match &part.kind {
1316                WordPart::CommandSubstitution { body, .. }
1317                | WordPart::ProcessSubstitution { body, .. } => return Some(body),
1318                WordPart::DoubleQuoted { parts, .. } => {
1319                    if let Some(body) = first_command_substitution_body(parts) {
1320                        return Some(body);
1321                    }
1322                }
1323                _ => {}
1324            }
1325        }
1326        None
1327    }
1328
1329    fn simple_command_names(visits: Vec<facts::CommandVisit<'_>>, source: &str) -> Vec<String> {
1330        visits
1331            .into_iter()
1332            .filter_map(|visit| {
1333                let Command::Simple(command) = visit.command else {
1334                    return None;
1335                };
1336                let span = command.name.span;
1337                Some(source[span.start.offset..span.end.offset].to_owned())
1338            })
1339            .collect()
1340    }
1341
1342    #[test]
1343    fn linter_semantic_artifacts_iterate_body_commands_without_deeper_nested_words() {
1344        let source = "echo \"$(if probe; then printf \"$(inner)\"; fi)\"\n";
1345        let output = Parser::new(source).parse().unwrap();
1346        let indexer = Indexer::new(source, &output);
1347        let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
1348        let Command::Simple(command) = &output.file.body.stmts[0].command else {
1349            panic!("expected simple command");
1350        };
1351        let body = first_command_substitution_body(&command.args[0].parts)
1352            .expect("expected command substitution body");
1353
1354        let same_body_names =
1355            simple_command_names(semantic.command_visits_in_body(body, false), source);
1356        let nested_names =
1357            simple_command_names(semantic.command_visits_in_body(body, true), source);
1358
1359        assert_eq!(same_body_names, vec!["probe", "printf"]);
1360        assert_eq!(nested_names, vec!["probe", "printf", "inner"]);
1361    }
1362
1363    #[test]
1364    fn linter_semantic_artifacts_keep_function_body_nested_depths() {
1365        let source = "echo \"$(f() { printf \"$(inner)\"; }; f)\"\n";
1366        let output = Parser::new(source).parse().unwrap();
1367        let indexer = Indexer::new(source, &output);
1368        let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
1369        let Command::Simple(command) = &output.file.body.stmts[0].command else {
1370            panic!("expected simple command");
1371        };
1372        let body = first_command_substitution_body(&command.args[0].parts)
1373            .expect("expected command substitution body");
1374
1375        let same_body_names =
1376            simple_command_names(semantic.command_visits_in_body(body, false), source);
1377        let nested_names =
1378            simple_command_names(semantic.command_visits_in_body(body, true), source);
1379
1380        assert_eq!(same_body_names, vec!["printf", "f"]);
1381        assert_eq!(nested_names, vec!["printf", "inner", "f"]);
1382    }
1383
1384    #[test]
1385    fn default_settings_run_without_emitting_noop_diagnostics() {
1386        let diagnostics = lint("#!/bin/bash\necho ok\n", &LinterSettings::default());
1387        assert!(diagnostics.is_empty());
1388    }
1389
1390    #[test]
1391    fn lint_file_preserves_parse_rule_diagnostics() {
1392        let source = "#!/bin/sh\n{ :; } always { :; }\n";
1393        let parse_result = Parser::new(source).parse();
1394        let settings = LinterSettings::for_rule(Rule::ZshAlwaysBlock);
1395        let shellcheck_map = ShellCheckCodeMap::default();
1396        let diagnostics = AnalysisRequest::from_parse_result(&parse_result, source, &settings)
1397            .with_shellcheck_map(&shellcheck_map)
1398            .lint();
1399
1400        assert_eq!(diagnostics.len(), 1);
1401        assert_eq!(diagnostics[0].rule, Rule::ZshAlwaysBlock);
1402        assert_eq!(diagnostics[0].span.slice(source), "always");
1403    }
1404
1405    #[test]
1406    fn analyze_file_returns_semantic_model_and_diagnostics() {
1407        let source = "#!/bin/bash\nvalue=ok\necho \"$value\"\n";
1408        let output = Parser::new(source).parse().unwrap();
1409        let settings = LinterSettings::default();
1410        let result = AnalysisRequest::from_file(&output.file, source, &settings).analyze();
1411
1412        assert!(result.diagnostics.is_empty());
1413        assert!(!result.semantic.scopes().is_empty());
1414        assert!(!result.semantic.bindings().is_empty());
1415    }
1416
1417    #[test]
1418    fn plugin_entrypoints_receive_ambient_contracts_while_summarizing() {
1419        struct EntrypointResolver {
1420            entrypoint: PathBuf,
1421        }
1422
1423        impl PluginResolver for EntrypointResolver {
1424            fn additional_plugin_requests(&self, _source_path: &Path) -> Vec<PluginRequest> {
1425                vec![PluginRequest {
1426                    framework: PluginFramework::ExplicitFilesystem,
1427                    kind: PluginRequestKind::Entrypoint,
1428                    name: self.entrypoint.to_string_lossy().into_owned(),
1429                    span: Span::new(),
1430                    explicit: true,
1431                    root_hint: None,
1432                }]
1433            }
1434
1435            fn resolve_plugin_request(
1436                &self,
1437                _source_path: &Path,
1438                request: &PluginRequest,
1439            ) -> PluginResolution {
1440                if request.kind == PluginRequestKind::Entrypoint {
1441                    PluginResolution {
1442                        entrypoints: vec![self.entrypoint.clone()],
1443                        file_entry_contracts: Vec::new(),
1444                        requesting_file_contract: FileContract::default(),
1445                    }
1446                } else {
1447                    PluginResolution::default()
1448                }
1449            }
1450        }
1451
1452        let temp = tempdir().unwrap();
1453        let main = temp.path().join(".zshrc");
1454        let plugin = temp
1455            .path()
1456            .join("zsh-autosuggestions")
1457            .join("zsh-autosuggestions.plugin.zsh");
1458        fs::create_dir_all(plugin.parent().unwrap()).unwrap();
1459        fs::write(&main, "#!/bin/zsh\n").unwrap();
1460        fs::write(&plugin, "print -r -- \"$widgets\"\n").unwrap();
1461
1462        let diagnostics = lint_path_for_rule_with_plugin_resolver(
1463            &main,
1464            Rule::UndefinedVariable,
1465            &EntrypointResolver { entrypoint: plugin },
1466        );
1467
1468        assert!(
1469            diagnostics.is_empty(),
1470            "plugin ambient runtime reads leaked into caller diagnostics: {diagnostics:?}"
1471        );
1472    }
1473
1474    #[test]
1475    fn parse_error_position_falls_back_to_first_diagnostic_span() {
1476        let file = Parser::new("#!/bin/bash\n").parse().unwrap().file;
1477        let diagnostic_start = Position {
1478            line: 3,
1479            column: 2,
1480            offset: 14,
1481        };
1482        let parse_result = ParseResult {
1483            file,
1484            diagnostics: vec![ParseDiagnostic {
1485                message: "expected command".to_owned(),
1486                span: Span::at(diagnostic_start),
1487            }],
1488            status: ParseStatus::Recovered,
1489            terminal_error: Some(ParseError::parse("expected command")),
1490            syntax_facts: SyntaxFacts::default(),
1491        };
1492
1493        assert_eq!(parse_error_position(&parse_result), Some((3, 2)));
1494    }
1495
1496    #[test]
1497    fn empty_rule_set_is_a_noop() {
1498        let diagnostics = lint(
1499            "#!/bin/bash\necho ok\n",
1500            &LinterSettings {
1501                rules: RuleSet::EMPTY,
1502                ..LinterSettings::default()
1503            },
1504        );
1505
1506        assert!(diagnostics.is_empty());
1507    }
1508
1509    #[test]
1510    fn shell_inference_uses_path_when_shebang_is_missing() {
1511        let source = "local value=ok\n";
1512        let diagnostics = lint_named_source(
1513            Path::new("/tmp/example.bash"),
1514            source,
1515            &LinterSettings::for_rule(Rule::LocalTopLevel),
1516        );
1517
1518        assert_eq!(diagnostics.len(), 1);
1519        assert_eq!(diagnostics[0].rule, Rule::LocalTopLevel);
1520    }
1521
1522    #[test]
1523    fn project_specific_paths_do_not_suppress_undefined_variables() {
1524        let diagnostics = lint_named_source(
1525            Path::new("/tmp/void-packages/common/build-style/void-cross.sh"),
1526            "\
1527build() {
1528printf '%s\\n' \"$XBPS_SRCPKGDIR\" \"$configure_args\" \"$wrksrc\"
1529}
1530build
1531",
1532            &LinterSettings::for_rule(Rule::UndefinedVariable),
1533        );
1534
1535        assert!(
1536            diagnostics
1537                .iter()
1538                .any(|diagnostic| diagnostic.message.contains("configure_args"))
1539        );
1540        assert!(
1541            diagnostics
1542                .iter()
1543                .any(|diagnostic| diagnostic.message.contains("wrksrc"))
1544        );
1545    }
1546
1547    #[test]
1548    fn flattened_corpus_paths_do_not_suppress_undefined_variables() {
1549        let diagnostics = lint_named_source(
1550            Path::new("/tmp/scripts/void-linux__void-packages__common__build-style__void-cross.sh"),
1551            "\
1552build() {
1553printf '%s\\n' \"$XBPS_SRCPKGDIR\" \"$configure_args\" \"$wrksrc\"
1554}
1555build
1556",
1557            &LinterSettings::for_rule(Rule::UndefinedVariable),
1558        );
1559
1560        assert!(
1561            diagnostics
1562                .iter()
1563                .any(|diagnostic| diagnostic.message.contains("configure_args"))
1564        );
1565        assert!(
1566            diagnostics
1567                .iter()
1568                .any(|diagnostic| diagnostic.message.contains("wrksrc"))
1569        );
1570    }
1571
1572    #[test]
1573    fn sourced_theme_contract_does_not_suppress_runtime_color_reads() {
1574        let diagnostics = lint_named_source(
1575            Path::new("/tmp/bash-it/themes/minimal/minimal.theme.bash"),
1576            "\
1577prompt_command() {
1578  PS1=\"$green $reset_color\"
1579}
1580PROMPT_COMMAND=prompt_command
1581",
1582            &LinterSettings::for_rule(Rule::UndefinedVariable),
1583        );
1584
1585        assert!(
1586            diagnostics
1587                .iter()
1588                .any(|diagnostic| diagnostic.message.contains("green"))
1589        );
1590        assert!(
1591            diagnostics
1592                .iter()
1593                .any(|diagnostic| diagnostic.message.contains("reset_color"))
1594        );
1595    }
1596
1597    #[test]
1598    fn generic_theme_directory_does_not_suppress_palette_reads() {
1599        let diagnostics = lint_named_source(
1600            Path::new("/tmp/project/themes/minimal.theme.bash"),
1601            "\
1602render_prompt() {
1603  printf '%s\\n' \"$green\" \"$reset_color\"
1604}
1605",
1606            &LinterSettings::for_rule(Rule::UndefinedVariable),
1607        );
1608
1609        assert!(
1610            diagnostics
1611                .iter()
1612                .any(|diagnostic| diagnostic.message.contains("green"))
1613        );
1614        assert!(
1615            diagnostics
1616                .iter()
1617                .any(|diagnostic| diagnostic.message.contains("reset_color"))
1618        );
1619    }
1620
1621    #[test]
1622    fn generic_completion_directory_does_not_suppress_helper_reads() {
1623        let diagnostics = lint_named_source(
1624            Path::new("/tmp/project/completions/example.sh"),
1625            "\
1626complete_example() {
1627  printf '%s\\n' \"$cur\" \"$cword\"
1628}
1629",
1630            &LinterSettings::for_rule(Rule::UndefinedVariable),
1631        );
1632
1633        assert!(
1634            diagnostics
1635                .iter()
1636                .any(|diagnostic| diagnostic.message.contains("cur"))
1637        );
1638        assert!(
1639            diagnostics
1640                .iter()
1641                .any(|diagnostic| diagnostic.message.contains("cword"))
1642        );
1643    }
1644
1645    #[test]
1646    fn generic_completion_directory_with_compreply_does_not_suppress_helper_reads() {
1647        let diagnostics = lint_named_source(
1648            Path::new("/tmp/project/completions/example.sh"),
1649            "\
1650complete_example() {
1651  COMPREPLY=()
1652  printf '%s\\n' \"$cur\" \"$cword\"
1653}
1654",
1655            &LinterSettings::for_rule(Rule::UndefinedVariable),
1656        );
1657
1658        assert!(
1659            diagnostics
1660                .iter()
1661                .any(|diagnostic| diagnostic.message.contains("cur"))
1662        );
1663        assert!(
1664            diagnostics
1665                .iter()
1666                .any(|diagnostic| diagnostic.message.contains("cword"))
1667        );
1668    }
1669
1670    #[test]
1671    fn bash_completion_directory_without_initializer_does_not_suppress_helper_reads() {
1672        let diagnostics = lint_named_source(
1673            Path::new("/tmp/bash-completion/completions/example.bash"),
1674            "\
1675complete_example() {
1676  COMPREPLY=()
1677  printf '%s\\n' \"$cur\" \"$cword\"
1678}
1679",
1680            &LinterSettings::for_rule(Rule::UndefinedVariable),
1681        );
1682
1683        assert!(
1684            diagnostics
1685                .iter()
1686                .any(|diagnostic| diagnostic.message.contains("cur"))
1687        );
1688        assert!(
1689            diagnostics
1690                .iter()
1691                .any(|diagnostic| diagnostic.message.contains("cword"))
1692        );
1693    }
1694
1695    #[test]
1696    fn bash_completion_directory_with_initializer_does_not_suppress_helper_reads() {
1697        let diagnostics = lint_named_source(
1698            Path::new("/tmp/bash-completion/completions/example.bash"),
1699            "\
1700complete_example() {
1701  _init_completion || return
1702  printf '%s\\n' \"$cur\" \"$cword\" \"$comp_args\"
1703}
1704",
1705            &LinterSettings::for_rule(Rule::UndefinedVariable),
1706        );
1707
1708        assert!(
1709            diagnostics
1710                .iter()
1711                .any(|diagnostic| diagnostic.message.contains("cur"))
1712        );
1713        assert!(
1714            diagnostics
1715                .iter()
1716                .any(|diagnostic| diagnostic.message.contains("cword"))
1717        );
1718        assert!(
1719            diagnostics
1720                .iter()
1721                .any(|diagnostic| diagnostic.message.contains("comp_args"))
1722        );
1723    }
1724
1725    #[test]
1726    fn bash_completion_directory_with_commented_initializer_does_not_suppress_helper_reads() {
1727        let diagnostics = lint_named_source(
1728            Path::new("/tmp/bash-completion/completions/example.bash"),
1729            "\
1730# TODO: call _init_completion later
1731complete_example() {
1732  printf '%s\\n' \"$cur\" \"$cword\"
1733}
1734",
1735            &LinterSettings::for_rule(Rule::UndefinedVariable),
1736        );
1737
1738        assert!(
1739            diagnostics
1740                .iter()
1741                .any(|diagnostic| diagnostic.message.contains("cur"))
1742        );
1743        assert!(
1744            diagnostics
1745                .iter()
1746                .any(|diagnostic| diagnostic.message.contains("cword"))
1747        );
1748    }
1749
1750    #[test]
1751    fn bash_completion_directory_with_wrapper_identifier_does_not_suppress_helper_reads() {
1752        let diagnostics = lint_named_source(
1753            Path::new("/tmp/bash-completion/completions/example.bash"),
1754            "\
1755complete_example() {
1756  my_init_completion_wrapper || return
1757  printf '%s\\n' \"$cur\" \"$cword\"
1758}
1759",
1760            &LinterSettings::for_rule(Rule::UndefinedVariable),
1761        );
1762
1763        assert!(
1764            diagnostics
1765                .iter()
1766                .any(|diagnostic| diagnostic.message.contains("cur"))
1767        );
1768        assert!(
1769            diagnostics
1770                .iter()
1771                .any(|diagnostic| diagnostic.message.contains("cword"))
1772        );
1773    }
1774
1775    #[test]
1776    fn bash_completion_directory_with_initializer_definition_does_not_suppress_helper_reads() {
1777        let diagnostics = lint_named_source(
1778            Path::new("/tmp/bash-completion/completions/example.bash"),
1779            "\
1780_init_completion() {
1781  :
1782}
1783complete_example() {
1784  printf '%s\\n' \"$cur\" \"$cword\"
1785}
1786",
1787            &LinterSettings::for_rule(Rule::UndefinedVariable),
1788        );
1789
1790        assert!(
1791            diagnostics
1792                .iter()
1793                .any(|diagnostic| diagnostic.message.contains("cur"))
1794        );
1795        assert!(
1796            diagnostics
1797                .iter()
1798                .any(|diagnostic| diagnostic.message.contains("cword"))
1799        );
1800    }
1801
1802    #[test]
1803    fn bash_completion_directory_with_separator_comment_does_not_suppress_helper_reads() {
1804        let diagnostics = lint_named_source(
1805            Path::new("/tmp/bash-completion/completions/example.bash"),
1806            "\
1807noop;# _init_completion later
1808complete_example() {
1809  printf '%s\\n' \"$cur\" \"$cword\"
1810}
1811",
1812            &LinterSettings::for_rule(Rule::UndefinedVariable),
1813        );
1814
1815        assert!(
1816            diagnostics
1817                .iter()
1818                .any(|diagnostic| diagnostic.message.contains("cur"))
1819        );
1820        assert!(
1821            diagnostics
1822                .iter()
1823                .any(|diagnostic| diagnostic.message.contains("cword"))
1824        );
1825    }
1826
1827    #[test]
1828    fn bash_completion_directory_with_heredoc_initializer_does_not_suppress_helper_reads() {
1829        let diagnostics = lint_named_source(
1830            Path::new("/tmp/bash-completion/completions/example.bash"),
1831            "\
1832cat <<EOF
1833_init_completion
1834EOF
1835complete_example() {
1836  printf '%s\\n' \"$cur\" \"$cword\"
1837}
1838",
1839            &LinterSettings::for_rule(Rule::UndefinedVariable),
1840        );
1841
1842        assert!(
1843            diagnostics
1844                .iter()
1845                .any(|diagnostic| diagnostic.message.contains("cur"))
1846        );
1847        assert!(
1848            diagnostics
1849                .iter()
1850                .any(|diagnostic| diagnostic.message.contains("cword"))
1851        );
1852    }
1853
1854    #[test]
1855    fn sourced_runtime_contract_does_not_mark_arbitrary_assignments_used() {
1856        let diagnostics = lint_named_source(
1857            Path::new("/tmp/rvm/scripts/cleanup"),
1858            "\
1859rvm_base_except=\"selector\"
1860cleanup() { :; }
1861",
1862            &LinterSettings::for_rule(Rule::UnusedAssignment),
1863        );
1864
1865        assert_eq!(diagnostics.len(), 1);
1866        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
1867    }
1868
1869    #[test]
1870    fn sourced_module_contract_does_not_suppress_arbitrary_runtime_state_reads() {
1871        let diagnostics = lint_named_source(
1872            Path::new("/tmp/LinuxGSM/lgsm/modules/command_backup.sh"),
1873            "\
1874commandname=\"BACKUP\"
1875backup_run() {
1876  printf '%s\\n' \"$lockdir\" \"$commandname\"
1877}
1878",
1879            &LinterSettings::for_rule(Rule::UndefinedVariable),
1880        );
1881
1882        assert_eq!(diagnostics.len(), 1);
1883        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
1884    }
1885
1886    #[test]
1887    fn prefix_name_expansions_do_not_trigger_c006() {
1888        let diagnostics = lint_named_source(
1889            Path::new("/tmp/project/plain.sh"),
1890            "unset \"${!completion_prefix@}\"\n",
1891            &LinterSettings::for_rule(Rule::UndefinedVariable),
1892        );
1893
1894        assert!(diagnostics.is_empty());
1895    }
1896
1897    #[test]
1898    fn project_closure_context_without_a_provider_still_reports_c006() {
1899        let diagnostics = lint_named_source(
1900            Path::new("/tmp/project/scripts/helper.sh"),
1901            "\
1902# shellcheck source=helpers.sh
1903. ./helpers.sh
1904printf '%s\\n' \"$pkgname\"
1905",
1906            &LinterSettings::for_rule(Rule::UndefinedVariable),
1907        );
1908
1909        assert_eq!(diagnostics.len(), 1);
1910        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
1911    }
1912
1913    #[test]
1914    fn void_packages_paths_without_required_source_anchors_still_report_c006() {
1915        let xbps_src_diagnostics = lint_named_source(
1916            Path::new("/tmp/void-packages/common/xbps-src/shutils/common.sh"),
1917            "printf '%s\\n' \"$build_style\"\n",
1918            &LinterSettings::for_rule(Rule::UndefinedVariable),
1919        );
1920        assert_eq!(xbps_src_diagnostics.len(), 1);
1921        assert_eq!(xbps_src_diagnostics[0].rule, Rule::UndefinedVariable);
1922
1923        let pycompile_diagnostics = lint_named_source(
1924            Path::new("/tmp/void-packages/srcpkgs/xbps-triggers/files/pycompile"),
1925            "printf '%s\\n' \"$pycompile_version\"\n",
1926            &LinterSettings::for_rule(Rule::UndefinedVariable),
1927        );
1928        assert_eq!(pycompile_diagnostics.len(), 1);
1929        assert_eq!(pycompile_diagnostics[0].rule, Rule::UndefinedVariable);
1930    }
1931
1932    #[test]
1933    fn project_closure_function_contract_suppresses_c006_when_called() {
1934        let temp = tempdir().unwrap();
1935        let main = temp.path().join("main.sh");
1936        let helper = temp.path().join("helper.sh");
1937        fs::write(
1938            &main,
1939            "\
1940#!/bin/sh
1941. ./helper.sh
1942set_flag
1943printf '%s\\n' \"$flag\"
1944",
1945        )
1946        .unwrap();
1947        fs::write(
1948            &helper,
1949            "\
1950set_flag() {
1951  flag=1
1952}
1953",
1954        )
1955        .unwrap();
1956
1957        let diagnostics = lint_path_for_rule(&main, Rule::UndefinedVariable);
1958        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
1959    }
1960
1961    #[test]
1962    fn sourced_zsh_helper_imports_bindings_after_zsh_only_syntax() {
1963        let temp = tempdir().unwrap();
1964        let main = temp.path().join("main.zsh");
1965        let helper = temp.path().join("helper.zsh");
1966        fs::write(
1967            &main,
1968            "\
1969#!/bin/zsh
1970. ./helper.zsh
1971print \"$helper_value\"
1972",
1973        )
1974        .unwrap();
1975        fs::write(
1976            &helper,
1977            "\
1978#!/bin/zsh
1979repeat 1; do print loaded; done
1980helper_value=ready
1981",
1982        )
1983        .unwrap();
1984
1985        let diagnostics = lint_path_for_rule(&main, Rule::UndefinedVariable);
1986        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
1987    }
1988
1989    fn obscure_zsh_linter_stress_source() -> &'static str {
1990        r#"#!/bin/zsh
1991() {
1992  emulate -L zsh
1993  setopt extendedglob
1994  local -a matches=(src/**/*.zsh(.N:t:r))
1995  print -r -- ${(j:,:)matches}
1996} one two
1997
1998{
1999  if [[ -n ${commands[zsh]} ]] {
2000    print -r -- ok
2001  } elif (( ${+commands[false]} )) {
2002    print -r -- maybe
2003  } else {
2004    print -r -- missing
2005  }
2006} always {
2007  print -r -- cleanup
2008}
2009
2010repeat 2 print -r -- tick
2011
2012foreach item (alpha beta gamma) {
2013  print -r -- ${item:u}
2014}
2015
2016typeset -A colors=(
2017  [normal]=black
2018  [warning]=yellow
2019  [error]=red
2020)
2021print -r -- ${colors[(i)warn*]} ${colors[(r)r*]}
2022colors[(I)e*]=brightred
2023
2024local target=/tmp/archive.tar.gz
2025print -r -- ${${target:t}:r} ${${:-$target}:h}
2026print -r -- ${(Q)${:-one\ two}} ${(%)${:-%n@%m}}
2027
2028local -a words=(one two three)
2029print -r -- ${(j:|:)words}
2030print -r -- ${(qqq)${(F)words}}
2031
2032print -r -- **/*.zsh(.DN:t:r)
2033print -r -- *(^@N) *(.om[1,3])
2034
2035diff =(print -r -- left) <(print -r -- right)
2036cat > >(sed 's/^/[out] /') <<< ${:-payload}
2037
2038print -r -- message >out.log >audit.log
2039print -r -- quiet &>/dev/null &|
2040
2041case $OSTYPE in
2042  (darwin|freebsd)<->)
2043    print -r -- bsd ;|
2044  linux(|-gnu))
2045    print -r -- linux ;&
2046  *)
2047    print -r -- other ;;
2048esac
2049
2050if [[ $file == (#b)(*/)([^/]##).(zsh|plugin)(#e) ]]; then
2051  print -r -- $match[1] $match[2]
2052fi
2053
2054zparseopts -D -E -F -- \
2055  h=help -help=help \
2056  v+:=verbose -verbose+:=verbose \
2057  o:=output -output:=output
2058
2059noglob command print -r -- **/*(N)
2060whence -m 'z*' >/dev/null
2061
2062PS1=$'%F{green}%n%f:%~ %# '
2063local rendered=${(%)PS1}
2064print -r -- $rendered
2065
2066integer count=${#path}
2067(( count += ${+commands[git]} ? path[(I)*bin*] : 0 ))
2068print -r -- $count
2069
2070coproc {
2071  print -r -- request
2072  read -r reply
2073  print -r -- $reply
2074}
2075
2076while getopts ":wtfvh-:" opt; do
2077  case "$opt" in
2078    -)
2079      case "${OPTARG}" in
2080        wait)
2081          WAIT=1
2082          ;;
2083        help|version)
2084          REDIRECT_STDERR=1
2085          EXPECT_OUTPUT=1
2086          ;;
2087        foreground|benchmark|benchmark-test|test)
2088          EXPECT_OUTPUT=1
2089          ;;
2090      esac
2091      ;;
2092    w)
2093      WAIT=1
2094      ;;
2095    h|v)
2096      REDIRECT_STDERR=1
2097      EXPECT_OUTPUT=1
2098      ;;
2099    f|t)
2100      EXPECT_OUTPUT=1
2101      ;;
2102  esac
2103done
2104
2105filelist=()
2106fid=0
2107find "$prefix/$folder" -type l | while read file; do
2108  target=`$readlink $file | grep '/\.npm/'`
2109  if [ "x$target" != "x" ]; then
2110    filelist[$fid]="$file"
2111    let 'fid++'
2112    base=`basename "$file"`
2113    find "`dirname $file`" -type l -name "$base"'*' \
2114      | while read link; do
2115          filelist[$fid]="$link"
2116          let 'fid++'
2117        done
2118  fi
2119done
2120
2121args=()
2122if [[ -n $verbose ]]; then
2123  args+=("--reporter=spec")
2124else
2125  args+=("--reporter=singleline")
2126fi
2127
2128case ${mode} in
2129  valgrind)
2130    valgrind                                     \
2131      --suppressions=./script/util/valgrind.supp \
2132      --dsymutil=yes                             \
2133      --leak-check=${leak_check}                 \
2134      $cmd "${args[@]}" 2>&1 |                   \
2135      grep --color -E '\w+_tests?.cc:\d+|$'
2136    ;;
2137  SVG)
2138    $cmd "${args[@]}" 2> >(grep -v 'Assertion failed' | dot -Tsvg >> index.html)
2139    ;;
2140esac
2141
2142html_replace_tokens () {
2143  local url=$1
2144  sed "s|@NAME@|$name|g" \
2145    | sed "s|@URL@|$url|g" \
2146    | perl -p -e 's/<h1([^>]*)>(.*?)<\/h1>/<h1>\2<\/h1>/g' \
2147    | (if [ $(basename $(dirname $dest)) == "doc" ]; then
2148        perl -p -e 's/ href="\.\.\// href="/g'
2149      else
2150        cat
2151      fi)
2152}
2153
21540=${(%):-%N}
2155"#
2156    }
2157
2158    #[test]
2159    fn default_linter_handles_obscure_zsh_syntax_without_parse_rule_diagnostics() {
2160        let source = obscure_zsh_linter_stress_source();
2161        let path = Path::new("stress.zsh");
2162        let diagnostics =
2163            crate::test::test_snippet_at_path(path, source, &LinterSettings::default());
2164
2165        let parse_rules = [
2166            Rule::MissingFi,
2167            Rule::IfMissingThen,
2168            Rule::LoopWithoutEnd,
2169            Rule::MissingDoneInForLoop,
2170            Rule::DanglingElse,
2171            Rule::UntilMissingDo,
2172            Rule::IfBracketGlued,
2173            Rule::CPrototypeFragment,
2174        ];
2175        assert!(
2176            diagnostics
2177                .iter()
2178                .all(|diagnostic| !parse_rules.contains(&diagnostic.rule)),
2179            "unexpected parse-shaped diagnostics: {diagnostics:#?}"
2180        );
2181    }
2182
2183    #[test]
2184    fn native_zsh_portability_rules_ignore_obscure_zsh_syntax() {
2185        let source = obscure_zsh_linter_stress_source();
2186        let path = Path::new("stress.zsh");
2187        let settings = LinterSettings::for_rules([
2188            Rule::ZshRedirPipe,
2189            Rule::ZshBraceIf,
2190            Rule::ZshAlwaysBlock,
2191            Rule::ZshFlagExpansion,
2192            Rule::NestedZshSubstitution,
2193            Rule::ZshNestedExpansion,
2194            Rule::ZshPromptBracket,
2195            Rule::ZshAssignmentToZero,
2196            Rule::ZshParameterFlag,
2197            Rule::ZshArraySubscriptInCase,
2198            Rule::ZshParameterIndexFlag,
2199            Rule::MultiVarForLoop,
2200            Rule::ProcessSubstitution,
2201            Rule::HereString,
2202            Rule::Coproc,
2203            Rule::ArrayAssignment,
2204            Rule::ArrayReference,
2205        ])
2206        .with_shell(ShellDialect::Zsh);
2207        let diagnostics = crate::test::test_snippet_at_path(path, source, &settings);
2208
2209        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:#?}");
2210    }
2211
2212    #[test]
2213    fn sourced_zsh_helper_imports_bindings_after_obscure_native_syntax() {
2214        let temp = tempdir().unwrap();
2215        let main = temp.path().join("main.zsh");
2216        let helper = temp.path().join("helper.zsh");
2217        fs::write(
2218            &main,
2219            "\
2220#!/bin/zsh
2221. ./helper.zsh
2222print -r -- \"$helper_value\"
2223",
2224        )
2225        .unwrap();
2226        fs::write(
2227            &helper,
2228            r#"#!/bin/zsh
2229() {
2230  emulate -L zsh
2231  local -a matches=(src/**/*.zsh(.N:t:r))
2232  print -r -- ${(j:,:)matches}
2233}
2234
2235{
2236  if [[ -n ${commands[zsh]} ]] {
2237    print -r -- ok
2238  } else {
2239    print -r -- missing
2240  }
2241} always {
2242  print -r -- cleanup
2243}
2244
2245typeset -A colors=(
2246  [normal]=black
2247  [warning]=yellow
2248  [error]=red
2249)
2250print -r -- ${colors[(i)warn*]} ${colors[(r)r*]}
2251
2252foreach item (alpha beta gamma) {
2253  print -r -- ${item:u}
2254}
2255
2256print -r -- **/*.zsh(.DN:t:r)
2257print -r -- *(^@N) *(.om[1,3])
2258print -r -- quiet &>/dev/null &|
2259
2260case $OSTYPE in
2261  (darwin|freebsd)<->) print -r -- bsd ;|
2262  linux(|-gnu)) print -r -- linux ;&
2263  *) print -r -- other ;;
2264esac
2265
2266helper_value=ready
2267"#,
2268        )
2269        .unwrap();
2270
2271        let diagnostics = lint_path_for_rule(&main, Rule::UndefinedVariable);
2272        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2273    }
2274
2275    #[test]
2276    fn sourced_env_split_bash_helper_prefers_shebang_over_sh_extension() {
2277        let temp = tempdir().unwrap();
2278        let main = temp.path().join("main.sh");
2279        let helper = temp.path().join("helper.sh");
2280        fs::write(
2281            &main,
2282            "\
2283#!/bin/sh
2284. ./helper.sh
2285printf '%s\\n' \"$helper_value\"
2286",
2287        )
2288        .unwrap();
2289        fs::write(
2290            &helper,
2291            "\
2292#!/usr/bin/env -S bash -e
2293for ((i=0; i<1; i++)); do :; done
2294helper_value=ready
2295",
2296        )
2297        .unwrap();
2298
2299        let diagnostics = lint_path_for_rule(&main, Rule::UndefinedVariable);
2300        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2301    }
2302
2303    #[test]
2304    fn sourced_env_split_bash_helper_normalizes_shebang_path() {
2305        let temp = tempdir().unwrap();
2306        let main = temp.path().join("main.sh");
2307        let helper = temp.path().join("helper.sh");
2308        fs::write(
2309            &main,
2310            "\
2311#!/bin/sh
2312. ./helper.sh
2313printf '%s\\n' \"$helper_value\"
2314",
2315        )
2316        .unwrap();
2317        fs::write(
2318            &helper,
2319            "\
2320#!/usr/bin/env -S /bin/bash -e
2321for ((i=0; i<1; i++)); do :; done
2322helper_value=ready
2323",
2324        )
2325        .unwrap();
2326
2327        let diagnostics = lint_path_for_rule(&main, Rule::UndefinedVariable);
2328        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2329    }
2330
2331    #[test]
2332    fn sourced_env_split_bash_helper_skips_env_assignments() {
2333        let temp = tempdir().unwrap();
2334        let main = temp.path().join("main.sh");
2335        let helper = temp.path().join("helper.sh");
2336        fs::write(
2337            &main,
2338            "\
2339#!/bin/sh
2340. ./helper.sh
2341printf '%s\\n' \"$helper_value\"
2342",
2343        )
2344        .unwrap();
2345        fs::write(
2346            &helper,
2347            "\
2348#!/usr/bin/env -S FOO=1 bash -e
2349for ((i=0; i<1; i++)); do :; done
2350helper_value=ready
2351",
2352        )
2353        .unwrap();
2354
2355        let diagnostics = lint_path_for_rule(&main, Rule::UndefinedVariable);
2356        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2357    }
2358
2359    #[test]
2360    fn sourced_helper_reads_keep_c150_live_for_subshell_assignments() {
2361        let temp = tempdir().unwrap();
2362        let main = temp.path().join("main.sh");
2363        let helper = temp.path().join("helper.sh");
2364        fs::write(
2365            &main,
2366            "\
2367#!/bin/sh
2368(flag=1)
2369. ./helper.sh
2370",
2371        )
2372        .unwrap();
2373        fs::write(&helper, "printf '%s\\n' \"$flag\"\n").unwrap();
2374
2375        let source = fs::read_to_string(&main).unwrap();
2376        let diagnostics = lint_path_for_rule(&main, Rule::SubshellLocalAssignment);
2377
2378        assert_eq!(
2379            diagnostics
2380                .iter()
2381                .map(|diagnostic| diagnostic.span.slice(&source))
2382                .collect::<Vec<_>>(),
2383            vec!["flag"]
2384        );
2385    }
2386
2387    #[test]
2388    fn sourced_helper_reads_ignore_subshell_writes_after_same_command_resets() {
2389        let temp = tempdir().unwrap();
2390        let main = temp.path().join("main.sh");
2391        let helper = temp.path().join("helper.sh");
2392        fs::write(
2393            &main,
2394            "\
2395#!/bin/sh
2396(flag=1)
2397flag=2 . ./helper.sh
2398",
2399        )
2400        .unwrap();
2401        fs::write(&helper, "printf '%s\\n' \"$flag\"\n").unwrap();
2402
2403        let local_assignment_diagnostics = lint_path_for_rule(&main, Rule::SubshellLocalAssignment);
2404        assert!(
2405            local_assignment_diagnostics.is_empty(),
2406            "diagnostics: {local_assignment_diagnostics:?}"
2407        );
2408
2409        let side_effect_diagnostics = lint_path_for_rule(&main, Rule::SubshellSideEffect);
2410        assert!(
2411            side_effect_diagnostics.is_empty(),
2412            "diagnostics: {side_effect_diagnostics:?}"
2413        );
2414    }
2415
2416    #[test]
2417    fn quoted_heredoc_generated_shell_text_does_not_report_c006() {
2418        let diagnostics = lint_for_rule(
2419            "\
2420#!/bin/sh
2421build=\"$(command cat <<\\END
2422printf '%s\\n' \"$workdir\"
2423END
2424)\"
2425",
2426            Rule::UndefinedVariable,
2427        );
2428
2429        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2430    }
2431
2432    #[test]
2433    fn escaped_dollar_heredoc_generated_text_does_not_report_c006() {
2434        let diagnostics = lint_for_rule(
2435            "\
2436#!/bin/sh
2437cat <<EOF
2438\\${devtype} \\${devnum}
2439EOF
2440",
2441            Rule::UndefinedVariable,
2442        );
2443
2444        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2445    }
2446
2447    #[test]
2448    fn quoted_heredoc_generated_shell_text_does_not_report_c006_with_source_closure() {
2449        let temp = tempdir().unwrap();
2450        let main = temp.path().join("main.sh");
2451        fs::write(
2452            &main,
2453            "\
2454#!/bin/sh
2455build=\"$(command cat <<\\END
2456for formula in libiconv cmake git wget; do
2457  if command brew ls --version \"$formula\" >/dev/null; then
2458    command brew upgrade \"$formula\"
2459  else
2460    command brew install \"$formula\"
2461  fi
2462done
2463archflag=\"-march\"
2464nopltflag=\"-fno-plt\"
2465cflags=\"$archflag=$cpu $nopltflag\"
2466. \"$outdir\"/build.info
2467END
2468)\"
2469",
2470        )
2471        .unwrap();
2472
2473        let diagnostics = lint_path_for_rule(&main, Rule::UndefinedVariable);
2474        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2475    }
2476
2477    #[test]
2478    fn posix_quoted_heredoc_generated_shell_text_does_not_report_c006_with_source_closure() {
2479        let temp = tempdir().unwrap();
2480        let main = temp.path().join("main.sh");
2481        let source = "\
2482#!/bin/sh
2483build=\"$(command cat <<\\END
2484for formula in libiconv cmake git wget; do
2485  if command brew ls --version \"$formula\" >/dev/null; then
2486    command brew upgrade \"$formula\"
2487  else
2488    command brew install \"$formula\"
2489  fi
2490done
2491archflag=\"-march\"
2492nopltflag=\"-fno-plt\"
2493cflags=\"$archflag=$cpu $nopltflag\"
2494. \"$outdir\"/build.info
2495END
2496)\"
2497";
2498        fs::write(&main, source).unwrap();
2499
2500        let diagnostics = lint_named_source_with_parse_dialect(
2501            &main,
2502            source,
2503            ParseDialect::Posix,
2504            &LinterSettings::for_rule(Rule::UndefinedVariable),
2505        );
2506        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2507    }
2508
2509    #[test]
2510    fn posix_second_quoted_heredoc_generated_shell_text_does_not_report_c006_with_source_closure() {
2511        let temp = tempdir().unwrap();
2512        let main = temp.path().join("main.sh");
2513        let source = "\
2514#!/bin/sh
2515usage=\"$(command cat <<\\END
2516Usage
2517END
2518)\"
2519
2520build=\"$(command cat <<\\END
2521for formula in libiconv cmake git wget; do
2522  if command brew ls --version \"$formula\" >/dev/null; then
2523    command brew upgrade \"$formula\"
2524  else
2525    command brew install \"$formula\"
2526  fi
2527done
2528archflag=\"-march\"
2529nopltflag=\"-fno-plt\"
2530cflags=\"$archflag=$cpu $nopltflag\"
2531. \"$outdir\"/build.info
2532END
2533)\"
2534";
2535        fs::write(&main, source).unwrap();
2536
2537        let diagnostics = lint_named_source_with_parse_dialect(
2538            &main,
2539            source,
2540            ParseDialect::Posix,
2541            &LinterSettings::for_rule(Rule::UndefinedVariable),
2542        );
2543        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2544    }
2545
2546    #[test]
2547    fn escaped_dollar_heredoc_generated_text_does_not_report_c006_with_source_closure() {
2548        let temp = tempdir().unwrap();
2549        let main = temp.path().join("main.sh");
2550        fs::write(
2551            &main,
2552            "\
2553#!/bin/sh
2554cat <<EOF > ./postinst
2555if [ \"\\$1\" = \"configure\" ]; then
2556  for ver in 1 current; do
2557    for x in rewriteSystem rewriteURI; do
2558      xmlcatalog --noout --add \\$x http://example.test/xsl/\\$ver
2559    done
2560  done
2561fi
2562EOF
2563",
2564        )
2565        .unwrap();
2566
2567        let diagnostics = lint_path_for_rule(&main, Rule::UndefinedVariable);
2568        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2569    }
2570
2571    #[test]
2572    fn quoted_heredoc_generated_shell_text_with_nested_same_name_heredoc_does_not_report_c006() {
2573        let temp = tempdir().unwrap();
2574        let main = temp.path().join("main.sh");
2575        fs::write(
2576            &main,
2577            "\
2578#!/bin/sh
2579build=\"$(command cat <<\\END
2580for formula in libiconv cmake git wget; do
2581  if command brew ls --version \"$formula\" >/dev/null; then
2582    command brew upgrade \"$formula\"
2583  else
2584    command brew install \"$formula\"
2585  fi
2586done
2587archflag=\"-march\"
2588nopltflag=\"-fno-plt\"
2589cflags=\"$archflag=$cpu $nopltflag\"
2590command cat >&2 <<-END
2591\tSUCCESS
2592\tEND
2593END
2594)\"
2595",
2596        )
2597        .unwrap();
2598
2599        let diagnostics = lint_path_for_rule(&main, Rule::UndefinedVariable);
2600        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2601    }
2602
2603    #[test]
2604    fn tab_stripped_escaped_dollar_heredoc_generated_text_does_not_report_c006() {
2605        let temp = tempdir().unwrap();
2606        let main = temp.path().join("main.sh");
2607        fs::write(
2608            &main,
2609            "\
2610#!/bin/sh
2611cat <<- EOF > ./postinst
2612\tif [ \"\\$1\" = \"configure\" ]; then
2613\t\tfor ver in 1 current; do
2614\t\t\tfor x in rewriteSystem rewriteURI; do
2615\t\t\t\txmlcatalog --noout --add \\$x http://example.test/xsl/\\$ver
2616\t\t\tdone
2617\t\tdone
2618\tfi
2619\tEOF
2620",
2621        )
2622        .unwrap();
2623
2624        let diagnostics = lint_path_for_rule(&main, Rule::UndefinedVariable);
2625        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2626    }
2627
2628    #[test]
2629    fn posix_tab_stripped_escaped_dollar_heredoc_generated_text_does_not_report_c006() {
2630        let temp = tempdir().unwrap();
2631        let main = temp.path().join("main.sh");
2632        let source = "\
2633#!/bin/sh
2634cat <<- EOF > ./postinst
2635\tif [ \"$TERMUX_PACKAGE_FORMAT\" = \"pacman\" ] || [ \"\\$1\" = \"configure\" ]; then
2636\t\tfor ver in $TERMUX_PKG_VERSION current; do
2637\t\t\tfor x in rewriteSystem rewriteURI; do
2638\t\t\t\txmlcatalog --noout --add \\$x http://docbook.sourceforge.net/release/xsl-ns/\\$ver \\
2639\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-$TERMUX_PKG_VERSION\" \\
2640\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
2641\t\t\tdone
2642\t\tdone
2643\tfi
2644\tEOF
2645";
2646        fs::write(&main, source).unwrap();
2647
2648        let diagnostics = lint_named_source_with_parse_dialect(
2649            &main,
2650            source,
2651            ParseDialect::Posix,
2652            &LinterSettings::for_rule(Rule::UndefinedVariable),
2653        );
2654        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2655    }
2656
2657    #[test]
2658    fn posix_docbook_wrapper_does_not_report_c006_for_escaped_placeholders() {
2659        let temp = tempdir().unwrap();
2660        let main = temp.path().join("main.sh");
2661        let source = "\
2662#!/bin/sh
2663termux_step_create_debscripts() {
2664\tcat <<- EOF > ./postinst
2665\t#!$TERMUX_PREFIX/bin/sh
2666\tif [ \"$TERMUX_PACKAGE_FORMAT\" = \"pacman\" ] || [ \"\\$1\" = \"configure\" ]; then
2667\t\tfor ver in $TERMUX_PKG_VERSION current; do
2668\t\t\tfor x in rewriteSystem rewriteURI; do
2669\t\t\t\txmlcatalog --noout --add \\$x http://cdn.docbook.org/release/xsl/\\$ver \\
2670\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-$TERMUX_PKG_VERSION\" \\
2671\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
2672\
2673\t\t\t\txmlcatalog --noout --add \\$x http://docbook.sourceforge.net/release/xsl-ns/\\$ver \\
2674\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-$TERMUX_PKG_VERSION\" \\
2675\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
2676\
2677\t\t\t\txmlcatalog --noout --add \\$x http://docbook.sourceforge.net/release/xsl/\\$ver \\
2678\t\t\t\t\t\"$TERMUX_PREFIX/share/xml/docbook/xsl-stylesheets-${TERMUX_PKG_VERSION}-nons\" \\
2679\t\t\t\t\t\"$TERMUX_PREFIX/etc/xml/catalog\"
2680\t\t\tdone
2681\t\tdone
2682\tfi
2683\tEOF
2684}
2685";
2686        fs::write(&main, source).unwrap();
2687
2688        let diagnostics = lint_named_source_with_parse_dialect(
2689            &main,
2690            source,
2691            ParseDialect::Posix,
2692            &LinterSettings::for_rule(Rule::UndefinedVariable),
2693        );
2694        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2695    }
2696
2697    #[test]
2698    fn bash_quoted_heredoc_case_arm_text_does_not_report_c006() {
2699        let temp = tempdir().unwrap();
2700        let main = temp.path().join("main.sh");
2701        fs::write(
2702            &main,
2703            "\
2704#!/bin/sh
2705build=\"$(command cat <<\\END
2706case \"$gitstatus_kernel\" in
2707  linux)
2708    for formula in libiconv cmake git wget; do
2709      if command brew ls --version \"$formula\" >/dev/null; then
2710        command brew upgrade \"$formula\"
2711      else
2712        command brew install \"$formula\"
2713      fi
2714    done
2715  ;;
2716esac
2717command cat >&2 <<-END
2718\tSUCCESS
2719\tEND
2720END
2721)\"
2722",
2723        )
2724        .unwrap();
2725
2726        let diagnostics = lint_named_source_with_parse_dialect(
2727            &main,
2728            &fs::read_to_string(&main).unwrap(),
2729            ParseDialect::Bash,
2730            &LinterSettings::for_rule(Rule::UndefinedVariable),
2731        );
2732        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
2733    }
2734
2735    #[test]
2736    fn recovered_lint_diagnostics_keep_valid_spans_for_fuzz_regression() {
2737        let source = concat!(
2738            "$~h) echo help; exit 0 ;;\n",
2739            "  esac\n",
2740            "done\n",
2741            "\n",
2742            "# Should not trigger: one arm can handle m   a) alg=le are correlated.\n",
2743            "while g$OPTARG ;;\n",
2744            "    d) domain=$eultiple options.\n",
2745            "while ge}opts ':ab' opt; do\n",
2746            "  case \"$opt\" in\n",
2747            "   | ) ba: ;;\n",
2748            "  esac\n",
2749            "doneJ\n",
2750            "# Shou#!/bin/sh\n",
2751            "\n",
2752            "# Should trigger: getopts declares -o, but the matching case never handles itld not trigger: only cases over the getopts variab.\n",
2753            "while getopts ':a:d:o:h' OPT; do\n",
2754            "  case \"$OPT\" in\n",
2755            "    a) alg=le are correlated.\n",
2756            "while g$OPTARG ;;\n",
2757            "    d) domain=$etoptA "
2758        );
2759        let cases = [
2760            (Some(Path::new("fuzz.sh")), ParseDialect::Posix),
2761            (Some(Path::new("fuzz.bash")), ParseDialect::Bash),
2762            (Some(Path::new("fuzz.mksh")), ParseDialect::Mksh),
2763            (Some(Path::new("fuzz.zsh")), ParseDialect::Zsh),
2764            (None, ParseDialect::Posix),
2765            (None, ParseDialect::Bash),
2766            (None, ParseDialect::Mksh),
2767            (None, ParseDialect::Zsh),
2768        ];
2769
2770        for (path, dialect) in cases {
2771            let parse_result = Parser::with_dialect(source, dialect).parse();
2772            let settings = LinterSettings::default();
2773            let shellcheck_map = ShellCheckCodeMap::default();
2774            let diagnostics = AnalysisRequest::from_parse_result(&parse_result, source, &settings)
2775                .with_optional_source_path(path)
2776                .with_shellcheck_map(&shellcheck_map)
2777                .lint();
2778
2779            for diagnostic in diagnostics {
2780                assert!(
2781                    diagnostic.span.start.offset <= diagnostic.span.end.offset,
2782                    "invalid span ordering for {} with path {:?} and dialect {:?}: {:?}",
2783                    diagnostic.code(),
2784                    path,
2785                    dialect,
2786                    diagnostic.span
2787                );
2788                assert!(
2789                    diagnostic.span.end.offset <= source.len(),
2790                    "span end out of bounds for {} with path {:?} and dialect {:?}: {:?}",
2791                    diagnostic.code(),
2792                    path,
2793                    dialect,
2794                    diagnostic.span
2795                );
2796                assert!(
2797                    source.is_char_boundary(diagnostic.span.start.offset),
2798                    "span start not on char boundary for {} with path {:?} and dialect {:?}: {:?}",
2799                    diagnostic.code(),
2800                    path,
2801                    dialect,
2802                    diagnostic.span
2803                );
2804                assert!(
2805                    source.is_char_boundary(diagnostic.span.end.offset),
2806                    "span end not on char boundary for {} with path {:?} and dialect {:?}: {:?}",
2807                    diagnostic.code(),
2808                    path,
2809                    dialect,
2810                    diagnostic.span
2811                );
2812            }
2813        }
2814    }
2815
2816    #[test]
2817    fn helper_library_functions_still_report_c006_without_calls() {
2818        let diagnostics = lint_named_source(
2819            Path::new("/tmp/project/lib/helper.sh"),
2820            "\
2821helper() {
2822  printf '%s\\n' \"$flag\"
2823}
2824",
2825            &LinterSettings::for_rule(Rule::UndefinedVariable),
2826        );
2827
2828        assert_eq!(diagnostics.len(), 1);
2829        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
2830    }
2831
2832    #[test]
2833    fn helper_library_functions_still_report_c006_when_called() {
2834        let diagnostics = lint_named_source(
2835            Path::new("/tmp/project/lib/helper.sh"),
2836            "\
2837helper() {
2838  printf '%s\\n' \"$flag\"
2839}
2840helper
2841",
2842            &LinterSettings::for_rule(Rule::UndefinedVariable),
2843        );
2844
2845        assert_eq!(diagnostics.len(), 1);
2846        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
2847    }
2848
2849    #[test]
2850    fn post_hoc_filtering_removes_only_suppressed_diagnostics() {
2851        let source = "\
2852echo ok
2853# shellcheck disable=SC2086
2854echo $foo
2855echo $bar
2856";
2857        let output = Parser::new(source).parse().unwrap();
2858        let indexer = Indexer::new(source, &output);
2859        let directives = parse_directives(
2860            source,
2861            indexer.comment_index(),
2862            &ShellCheckCodeMap::default(),
2863        );
2864        let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
2865        let suppressions =
2866            build_suppression_index_from_semantic(&directives, &output.file, source, &semantic)
2867                .unwrap();
2868
2869        let echo_foo = match &output.file.body[1].command {
2870            Command::Simple(command) => command.span,
2871            other => {
2872                debug_assert!(false, "expected simple command, got {other:?}");
2873                return;
2874            }
2875        };
2876        let echo_bar = match &output.file.body[2].command {
2877            Command::Simple(command) => command.span,
2878            other => {
2879                debug_assert!(false, "expected simple command, got {other:?}");
2880                return;
2881            }
2882        };
2883
2884        let mut diagnostics = vec![
2885            Diagnostic {
2886                rule: Rule::UnquotedExpansion,
2887                message: "first".to_owned(),
2888                severity: Rule::UnquotedExpansion.default_severity(),
2889                span: echo_foo,
2890                fix: None,
2891                fix_title: None,
2892            },
2893            Diagnostic {
2894                rule: Rule::UnquotedExpansion,
2895                message: "second".to_owned(),
2896                severity: Rule::UnquotedExpansion.default_severity(),
2897                span: echo_bar,
2898                fix: None,
2899                fix_title: None,
2900            },
2901        ];
2902
2903        filter_suppressed_diagnostics(&mut diagnostics, &indexer, &suppressions);
2904
2905        assert_eq!(diagnostics.len(), 1);
2906        assert_eq!(diagnostics[0].message, "second");
2907    }
2908
2909    #[test]
2910    fn shellcheck_disable_inside_function_suppresses_heredoc_body_diagnostics() {
2911        let source = "\
2912#!/bin/bash
2913echo ready
2914emit_file() {
2915  # shellcheck disable=SC2154
2916  cat \"$path\" <<EOF
2917value=$body_value
2918other=${other_value}
2919EOF
2920  echo \"$later\"
2921}
2922";
2923        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
2924
2925        assert_eq!(
2926            diagnostics
2927                .iter()
2928                .map(|diagnostic| diagnostic.span.slice(source))
2929                .collect::<Vec<_>>(),
2930            vec!["$later"]
2931        );
2932    }
2933
2934    #[test]
2935    fn undefined_variable_reports_escaped_ps4_prompt_reference_at_assignment() {
2936        let source = "\
2937#!/bin/bash
2938export PS4=\"+ \\${BASH_SOURCE##\\${rvm_path:-}} > \"
2939p=\"$rvm_path\"
2940";
2941        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
2942
2943        assert_eq!(
2944            diagnostics
2945                .iter()
2946                .map(|diagnostic| diagnostic.span.slice(source))
2947                .collect::<Vec<_>>(),
2948            vec!["PS4"]
2949        );
2950    }
2951
2952    #[test]
2953    fn undefined_variable_reports_trap_action_references_at_action_word() {
2954        let source = "\
2955#!/bin/sh
2956tmpdir=/tmp/example
2957trap 'ret=$?; rmdir \"$tmpdir/d\" \"$tmpdir\" 2>/dev/null; exit $ret' 0
2958";
2959        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
2960
2961        assert_eq!(
2962            diagnostics
2963                .iter()
2964                .map(|diagnostic| diagnostic.span.slice(source))
2965                .collect::<Vec<_>>(),
2966            vec!["'ret=$?; rmdir \"$tmpdir/d\" \"$tmpdir\" 2>/dev/null; exit $ret'"]
2967        );
2968    }
2969
2970    #[test]
2971    fn unused_assignment_flags_unread_variable() {
2972        let source = "#!/bin/sh\nfoo=1\n";
2973        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
2974        assert_eq!(diagnostics.len(), 1);
2975        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
2976        assert!(diagnostics[0].message.contains("foo"));
2977        assert_eq!(diagnostics[0].span.slice(source), "foo");
2978    }
2979
2980    #[test]
2981    fn unused_assignment_flags_indirect_only_target_by_default() {
2982        let source = "\
2983#!/bin/bash
2984target=ok
2985name=target
2986printf '%s\\n' \"${!name}\"
2987";
2988        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
2989
2990        assert_eq!(diagnostics.len(), 1);
2991        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
2992        assert_eq!(diagnostics[0].span.slice(source), "target");
2993    }
2994
2995    #[test]
2996    fn unused_assignment_can_keep_indirect_only_target_live_with_rule_option() {
2997        let diagnostics = lint(
2998            "\
2999#!/bin/bash
3000target=ok
3001name=target
3002printf '%s\\n' \"${!name}\"
3003",
3004            &LinterSettings::for_rule(Rule::UnusedAssignment)
3005                .with_c001_treat_indirect_expansion_targets_as_used(true),
3006        );
3007
3008        assert!(diagnostics.is_empty());
3009    }
3010
3011    #[test]
3012    fn unused_assignment_reports_declaration_only_targets_by_default() {
3013        let source = "\
3014#!/bin/bash
3015f(){
3016  local cur
3017  declare words
3018}
3019f
3020";
3021        let diagnostics = lint(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
3022
3023        assert_eq!(diagnostics.len(), 2);
3024        assert_eq!(diagnostics[0].span.slice(source), "cur");
3025        assert_eq!(diagnostics[1].span.slice(source), "words");
3026    }
3027
3028    #[test]
3029    fn unused_assignment_keeps_dynamic_target_arrays_live() {
3030        let diagnostics = lint(
3031            "\
3032#!/bin/bash
3033apache_args=(--apache)
3034nginx_args=(--nginx)
3035apache_args+=(--common)
3036nginx_args+=(--common)
3037web_server=apache
3038args_var=\"${web_server}_args[@]\"
3039printf '%s\\n' \"${!args_var}\"
3040",
3041            &LinterSettings::for_rule(Rule::UnusedAssignment),
3042        );
3043
3044        assert!(diagnostics.is_empty());
3045    }
3046
3047    #[test]
3048    fn unused_assignment_ignores_plain_underscore_bindings() {
3049        let diagnostics = lint_for_rule("#!/bin/bash\n_=1\n", Rule::UnusedAssignment);
3050
3051        assert!(diagnostics.is_empty());
3052    }
3053
3054    #[test]
3055    fn unused_assignment_ignores_leading_underscore_bindings() {
3056        let diagnostics = lint_for_rule(
3057            "#!/bin/bash\n_unused=1\n__unused=2\n",
3058            Rule::UnusedAssignment,
3059        );
3060
3061        assert!(diagnostics.is_empty());
3062    }
3063
3064    #[test]
3065    fn unused_assignment_reports_plain_rest_bindings() {
3066        let diagnostics = lint_for_rule("#!/bin/bash\nrest=1\nREST=2\n", Rule::UnusedAssignment);
3067
3068        assert_eq!(diagnostics.len(), 2);
3069        assert_eq!(diagnostics[0].span.start.line, 2);
3070        assert_eq!(diagnostics[1].span.start.line, 3);
3071    }
3072
3073    #[test]
3074    fn unused_assignment_ignores_underscore_read_targets() {
3075        let diagnostics = lint(
3076            "\
3077#!/bin/bash
3078printf 'x y\n' | while read -r _ value; do
3079  printf '%s\n' \"$value\"
3080done
3081",
3082            &LinterSettings::for_rule(Rule::UnusedAssignment),
3083        );
3084
3085        assert!(diagnostics.is_empty());
3086    }
3087
3088    #[test]
3089    fn unused_assignment_reports_read_target_name_span() {
3090        let source = "#!/bin/sh\nread -r foo\n";
3091        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
3092
3093        assert_eq!(diagnostics.len(), 1);
3094        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
3095        assert_eq!(diagnostics[0].span.slice(source), "foo");
3096    }
3097
3098    #[test]
3099    fn unused_assignment_reports_getopts_target_name_span() {
3100        let source = "\
3101#!/bin/sh
3102while getopts \"ab\" opt; do
3103  :
3104done
3105";
3106        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
3107        assert_eq!(diagnostics.len(), 1);
3108        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
3109        assert_eq!(diagnostics[0].span.slice(source), "opt");
3110    }
3111
3112    #[test]
3113    fn read_header_bindings_used_in_loop_body_are_not_flagged() {
3114        let diagnostics = lint(
3115            "\
3116#!/bin/sh
3117printf '%s\n' 'service safe ok yes' | while read UNIT EXPOSURE PREDICATE HAPPY; do
3118  printf '%s %s %s %s\n' \"$UNIT\" \"$EXPOSURE\" \"$PREDICATE\" \"$HAPPY\"
3119done
3120",
3121            &LinterSettings::for_rule(Rule::UnusedAssignment),
3122        );
3123
3124        assert!(diagnostics.is_empty());
3125    }
3126
3127    #[test]
3128    fn command_prefix_environment_assignment_is_not_flagged() {
3129        let diagnostics = lint(
3130            "\
3131#!/bin/sh
3132CFLAGS=\"$SLKCFLAGS\" make
3133DESTDIR=\"$pkgdir\" install
3134",
3135            &LinterSettings::for_rule(Rule::UnusedAssignment),
3136        );
3137
3138        assert!(diagnostics.is_empty());
3139    }
3140
3141    #[test]
3142    fn indirect_expansion_keeps_dynamic_target_arrays_live() {
3143        let diagnostics = lint(
3144            "\
3145#!/bin/bash
3146apache_args=(--apache)
3147nginx_args=(--nginx)
3148apache_args+=(--common)
3149nginx_args+=(--common)
3150web_server=apache
3151args_var=\"${web_server}_args[@]\"
3152printf '%s\\n' \"${!args_var}\"
3153",
3154            &LinterSettings::for_rule(Rule::UnusedAssignment),
3155        );
3156
3157        assert!(diagnostics.is_empty());
3158    }
3159
3160    #[test]
3161    fn array_append_used_by_later_expansion_is_not_flagged() {
3162        let diagnostics = lint(
3163            "\
3164#!/bin/bash
3165arr=(--first)
3166arr+=(--second)
3167printf '%s\\n' \"${arr[@]}\"
3168",
3169            &LinterSettings::for_rule(Rule::UnusedAssignment),
3170        );
3171
3172        assert!(diagnostics.is_empty());
3173    }
3174
3175    #[test]
3176    fn assignments_used_in_process_substitution_are_not_flagged() {
3177        let diagnostics = lint(
3178            "\
3179#!/bin/bash
3180f() {
3181  local opts
3182  case \"$1\" in
3183    a) opts=alpha ;;
3184    *) opts=beta ;;
3185  esac
3186  while IFS= read -r line; do :; done < <(printf '%s\\n' \"$opts\")
3187}
3188f a
3189",
3190            &LinterSettings::for_rule(Rule::UnusedAssignment),
3191        );
3192
3193        assert_eq!(diagnostics.len(), 1);
3194        assert_eq!(diagnostics[0].span.start.line, 8);
3195        assert_eq!(diagnostics[0].span.slice(
3196            "#!/bin/bash\nf() {\n  local opts\n  case \"$1\" in\n    a) opts=alpha ;;\n    *) opts=beta ;;\n  esac\n  while IFS= read -r line; do :; done < <(printf '%s\\n' \"$opts\")\n}\nf a\n"
3197        ), "line");
3198    }
3199
3200    #[test]
3201    fn overwritten_empty_initializers_only_report_the_later_dead_assignment() {
3202        let source = "\
3203#!/bin/bash
3204f() {
3205  local foo=
3206  foo=bar
3207}
3208f
3209";
3210        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
3211
3212        assert_eq!(diagnostics.len(), 1);
3213        assert_eq!(diagnostics[0].span.start.line, 4);
3214        assert_eq!(diagnostics[0].span.slice(source), "foo");
3215    }
3216
3217    #[test]
3218    fn substring_offset_arithmetic_reads_do_not_trigger_unused_assignment() {
3219        let diagnostics = lint(
3220            "\
3221#!/bin/bash
3222spinner() {
3223  local chars=\"/-\\\\|\"
3224  local spin_i=0
3225  while true; do
3226    printf '%s\\n' \"${chars:spin_i++%${#chars}:1}\"
3227  done
3228}
3229spinner
3230",
3231            &LinterSettings::for_rule(Rule::UnusedAssignment),
3232        );
3233
3234        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
3235    }
3236
3237    #[test]
3238    fn self_referential_assignments_are_not_flagged_unused() {
3239        let diagnostics = lint_for_rule(
3240            "\
3241#!/bin/sh
3242foo=\"$foo\"
3243bar=\"${bar:-fallback}\"
3244",
3245            Rule::UnusedAssignment,
3246        );
3247
3248        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
3249    }
3250
3251    #[test]
3252    fn nested_default_operand_followed_by_later_expansion_keeps_assignment_live() {
3253        let diagnostics = lint_for_rule(
3254            "\
3255#!/bin/sh
3256foo=bar
3257default=/tmp
3258cmd=\"${home:-\"${default}\"}'${foo}'\"
3259printf '%s\\n' \"$cmd\"
3260",
3261            Rule::UnusedAssignment,
3262        );
3263
3264        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
3265    }
3266
3267    #[test]
3268    fn unused_append_assignment_is_not_flagged() {
3269        let diagnostics = lint_for_rule("#!/bin/bash\nfoo+=bar\n", Rule::UnusedAssignment);
3270
3271        assert!(diagnostics.is_empty());
3272    }
3273
3274    #[test]
3275    fn later_defined_helper_assignment_to_caller_local_is_not_flagged() {
3276        let diagnostics = lint(
3277            "\
3278#!/bin/sh
3279main() {
3280  local status=''
3281  helper
3282  printf '%s\\n' \"$status\"
3283}
3284helper() {
3285  status=ok
3286}
3287main
3288",
3289            &LinterSettings::for_rule(Rule::UnusedAssignment),
3290        );
3291
3292        assert!(diagnostics.is_empty());
3293    }
3294
3295    #[test]
3296    fn later_defined_helper_array_append_to_caller_local_is_not_flagged() {
3297        let diagnostics = lint(
3298            "\
3299#!/bin/bash
3300main() {
3301  local errors=()
3302  helper
3303  printf '%s\\n' \"${errors[@]}\"
3304}
3305helper() {
3306  errors+=(oops)
3307}
3308main
3309",
3310            &LinterSettings::for_rule(Rule::UnusedAssignment),
3311        );
3312
3313        assert!(diagnostics.is_empty());
3314    }
3315
3316    #[test]
3317    fn read_implicitly_consumes_ifs_but_still_flags_unrelated_local() {
3318        let source = "\
3319#!/bin/bash
3320f() {
3321  local IFS=$'\\n'
3322  local unused=1
3323  read -d '' -ra reply < <(printf 'alpha\\nbeta\\0')
3324  printf '%s\\n' \"${reply[@]}\"
3325}
3326f
3327";
3328        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
3329        assert_eq!(diagnostics.len(), 1);
3330        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
3331        assert_eq!(diagnostics[0].span.slice(source), "unused");
3332    }
3333
3334    #[test]
3335    fn getopts_runtime_state_assignments_are_not_flagged() {
3336        let source = "\
3337#!/bin/sh
3338f() {
3339  local flag OPTIND=1 OPTARG='' OPTERR=0
3340  while getopts 'a:' flag; do :; done
3341}
3342f
3343";
3344        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
3345        assert_eq!(diagnostics.len(), 1);
3346        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
3347        assert_eq!(diagnostics[0].span.slice(source), "flag");
3348    }
3349
3350    #[test]
3351    fn global_ifs_assignment_is_not_flagged_but_unrelated_assignment_is() {
3352        let source = "\
3353#!/bin/bash
3354IFS=$'\\n\\t'
3355unused=1
3356echo ok
3357";
3358        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
3359        assert_eq!(diagnostics.len(), 1);
3360        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
3361        assert_eq!(diagnostics[0].span.slice(source), "unused");
3362    }
3363
3364    #[test]
3365    fn shell_runtime_assignments_are_not_flagged_but_unrelated_assignment_is() {
3366        let source = "\
3367#!/bin/sh
3368PATH=$PATH:/opt/custom
3369CDPATH=/tmp
3370LANG=C
3371LC_ALL=C
3372LC_TIME=C
3373unused=1
3374echo ok
3375";
3376        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
3377        assert_eq!(diagnostics.len(), 1);
3378        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
3379        assert_eq!(diagnostics[0].span.slice(source), "unused");
3380    }
3381
3382    #[test]
3383    fn special_runtime_assignments_are_not_flagged_but_unrelated_assignment_is() {
3384        let source = "\
3385#!/bin/bash
3386HOME=/tmp/home
3387SHELL=/bin/bash
3388TERM=xterm-256color
3389USER=builder
3390PWD=/tmp/work
3391HISTFILE=/tmp/history
3392HISTFILESIZE=unlimited
3393HISTIGNORE='ls:bg:fg:history'
3394HISTSIZE=-1
3395HISTTIMEFORMAT='%F %T '
3396COMP_WORDBREAKS=\"${COMP_WORDBREAKS//:/}\"
3397PROMPT_COMMAND='history -a'
3398BASH_ENV=/dev/null
3399BASH_XTRACEFD=9
3400ENV=/dev/null
3401INPUTRC=/tmp/inputrc
3402MAIL=/tmp/mail
3403OLDPWD=/tmp/old
3404PROMPT_DIRTRIM=2
3405SECONDS=0
3406TIMEFORMAT='%R'
3407TMOUT=30
3408PS1='prompt> '
3409PS2='continuation> '
3410PS3=''
3411PS4='+ '
3412COLUMNS=1
3413READLINE_POINT=0
3414unused=1
3415";
3416        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
3417        assert_eq!(diagnostics.len(), 1);
3418        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
3419        assert_eq!(diagnostics[0].span.slice(source), "unused");
3420    }
3421
3422    #[test]
3423    fn unrelated_array_assignment_is_still_flagged_with_indirect_expansion() {
3424        let source = "\
3425#!/bin/bash
3426apache_args=(--apache)
3427unused_args=(--unused)
3428args_var=apache_args[@]
3429printf '%s\\n' \"${!args_var}\"
3430";
3431        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
3432        assert_eq!(diagnostics.len(), 1);
3433        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
3434        assert_eq!(diagnostics[0].span.slice(source), "unused_args");
3435    }
3436
3437    #[test]
3438    fn used_variable_produces_no_diagnostic() {
3439        let diagnostics = lint(
3440            "#!/bin/sh\nfoo=1\necho \"$foo\"\n",
3441            &LinterSettings::for_rule(Rule::UnusedAssignment),
3442        );
3443        assert!(diagnostics.is_empty());
3444    }
3445
3446    #[test]
3447    fn parameter_default_operand_usage_is_not_flagged() {
3448        let source = "\
3449#!/bin/sh
3450repo_root=$(pwd)
3451cache_dir=${1:-\"$repo_root/.cache\"}
3452printf '%s\\n' \"$cache_dir\"
3453";
3454        let diagnostics = lint_named_source_with_parse_dialect(
3455            Path::new("/tmp/parameter-default.sh"),
3456            source,
3457            ParseDialect::Posix,
3458            &LinterSettings::for_rule(Rule::UnusedAssignment),
3459        );
3460
3461        assert!(diagnostics.is_empty());
3462    }
3463
3464    #[test]
3465    fn local_at_script_scope_is_flagged() {
3466        let diagnostics = lint(
3467            "#!/bin/bash\nlocal foo=bar\nprintf '%s\\n' \"$foo\"\n",
3468            &LinterSettings::for_rule(Rule::LocalTopLevel),
3469        );
3470        assert_eq!(diagnostics.len(), 1);
3471        assert_eq!(diagnostics[0].rule, Rule::LocalTopLevel);
3472    }
3473
3474    #[test]
3475    fn local_at_script_scope_in_sh_is_not_flagged() {
3476        let diagnostics = lint(
3477            "#!/bin/sh\nlocal foo=bar\nprintf '%s\\n' \"$foo\"\n",
3478            &LinterSettings::for_rule(Rule::LocalTopLevel),
3479        );
3480        assert!(diagnostics.is_empty());
3481    }
3482
3483    #[test]
3484    fn local_inside_function_is_not_flagged() {
3485        let diagnostics = lint(
3486            "\
3487#!/bin/bash
3488f() {
3489  local foo=bar
3490  printf '%s\\n' \"$foo\"
3491}
3492f
3493",
3494            &LinterSettings::for_rule(Rule::LocalTopLevel),
3495        );
3496        assert!(diagnostics.is_empty());
3497    }
3498
3499    #[test]
3500    fn local_is_flagged_in_sh_scripts() {
3501        let diagnostics = lint(
3502            "\
3503#!/bin/sh
3504f() {
3505  local foo=bar
3506  printf '%s\\n' \"$foo\"
3507}
3508f
3509",
3510            &LinterSettings::for_rule(Rule::LocalVariableInSh),
3511        );
3512        assert_eq!(diagnostics.len(), 1);
3513        assert_eq!(diagnostics[0].rule, Rule::LocalVariableInSh);
3514    }
3515
3516    #[test]
3517    fn local_in_bash_script_is_not_flagged_for_portability_rule() {
3518        let diagnostics = lint(
3519            "\
3520#!/bin/bash
3521f() {
3522  local foo=bar
3523  printf '%s\\n' \"$foo\"
3524}
3525f
3526",
3527            &LinterSettings::for_rule(Rule::LocalVariableInSh),
3528        );
3529        assert!(diagnostics.is_empty());
3530    }
3531
3532    #[test]
3533    fn function_keyword_in_sh_is_flagged() {
3534        let diagnostics = lint(
3535            "#!/bin/sh\nfunction f { :; }\n",
3536            &LinterSettings::for_rule(Rule::FunctionKeyword),
3537        );
3538        assert_eq!(diagnostics.len(), 1);
3539        assert_eq!(diagnostics[0].rule, Rule::FunctionKeyword);
3540    }
3541
3542    #[test]
3543    fn function_keyword_in_dash_is_flagged() {
3544        let diagnostics = lint(
3545            "#!/bin/dash\nfunction f { :; }\n",
3546            &LinterSettings::for_rule(Rule::FunctionKeyword),
3547        );
3548        assert_eq!(diagnostics.len(), 1);
3549        assert_eq!(diagnostics[0].rule, Rule::FunctionKeyword);
3550    }
3551
3552    #[test]
3553    fn function_keyword_with_parens_is_not_flagged_by_x004() {
3554        let diagnostics = lint(
3555            "#!/bin/sh\nfunction f() { :; }\n",
3556            &LinterSettings::for_rule(Rule::FunctionKeyword),
3557        );
3558        assert!(diagnostics.is_empty());
3559    }
3560
3561    #[test]
3562    fn function_keyword_in_bash_is_not_flagged_for_portability_rule() {
3563        let diagnostics = lint(
3564            "#!/bin/bash\nfunction f { :; }\n",
3565            &LinterSettings::for_rule(Rule::FunctionKeyword),
3566        );
3567        assert!(diagnostics.is_empty());
3568    }
3569
3570    #[test]
3571    fn function_keyword_with_parens_in_sh_is_flagged_by_x052() {
3572        let diagnostics = lint(
3573            "#!/bin/sh\nfunction f() { :; }\n",
3574            &LinterSettings::for_rule(Rule::FunctionKeywordInSh),
3575        );
3576        assert_eq!(diagnostics.len(), 1);
3577        assert_eq!(diagnostics[0].rule, Rule::FunctionKeywordInSh);
3578    }
3579
3580    #[test]
3581    fn function_keyword_without_parens_is_not_flagged_by_x052() {
3582        let diagnostics = lint(
3583            "#!/bin/sh\nfunction f { :; }\n",
3584            &LinterSettings::for_rule(Rule::FunctionKeywordInSh),
3585        );
3586        assert!(diagnostics.is_empty());
3587    }
3588
3589    #[test]
3590    fn function_keyword_with_parens_in_dash_is_flagged_by_x052() {
3591        let diagnostics = lint(
3592            "#!/bin/dash\nfunction f() { :; }\n",
3593            &LinterSettings::for_rule(Rule::FunctionKeywordInSh),
3594        );
3595        assert_eq!(diagnostics.len(), 1);
3596        assert_eq!(diagnostics[0].rule, Rule::FunctionKeywordInSh);
3597    }
3598
3599    #[test]
3600    fn function_keyword_with_parens_in_bash_is_not_flagged_by_x052() {
3601        let diagnostics = lint(
3602            "#!/bin/bash\nfunction f() { :; }\n",
3603            &LinterSettings::for_rule(Rule::FunctionKeywordInSh),
3604        );
3605        assert!(diagnostics.is_empty());
3606    }
3607
3608    #[test]
3609    fn source_inside_function_in_sh_is_not_flagged_by_x080() {
3610        let diagnostics = lint(
3611            "#!/bin/sh\nf() {\n  source ./helpers.sh\n}\n",
3612            &LinterSettings::for_rule(Rule::SourceInsideFunctionInSh),
3613        );
3614        assert!(diagnostics.is_empty());
3615    }
3616
3617    #[test]
3618    fn directive_pinned_source_inside_function_in_sh_is_flagged_by_x080() {
3619        let diagnostics = lint(
3620            "#!/bin/sh\nf() {\n  # shellcheck source=/dev/null\n  source ./helpers.sh\n}\n",
3621            &LinterSettings::for_rule(Rule::SourceInsideFunctionInSh),
3622        );
3623        assert_eq!(diagnostics.len(), 1);
3624        assert_eq!(diagnostics[0].rule, Rule::SourceInsideFunctionInSh);
3625    }
3626
3627    #[test]
3628    fn directive_pinned_source_inside_function_in_dash_is_flagged_by_x080() {
3629        let diagnostics = lint(
3630            "#!/bin/dash\nf() {\n  # shellcheck source=/dev/null\n  source ./helpers.sh\n}\n",
3631            &LinterSettings::for_rule(Rule::SourceInsideFunctionInSh),
3632        );
3633        assert_eq!(diagnostics.len(), 1);
3634        assert_eq!(diagnostics[0].rule, Rule::SourceInsideFunctionInSh);
3635    }
3636
3637    #[test]
3638    fn directive_pinned_guarded_source_inside_function_in_sh_is_flagged_by_x080() {
3639        let diagnostics = lint(
3640            "#!/bin/sh\nf() {\n  # shellcheck source=/dev/null\n  [ -r ./helpers.sh ] && source ./helpers.sh\n}\n",
3641            &LinterSettings::for_rule(Rule::SourceInsideFunctionInSh),
3642        );
3643        assert_eq!(diagnostics.len(), 1);
3644        assert_eq!(diagnostics[0].rule, Rule::SourceInsideFunctionInSh);
3645    }
3646
3647    #[test]
3648    fn directive_pinned_source_inside_function_command_substitution_in_sh_is_flagged_by_x080() {
3649        let diagnostics = lint(
3650            "#!/bin/sh\nf() {\n  version=$(\n    # shellcheck source=/dev/null\n    source ./helpers.sh && echo \"$name\"\n  )\n}\n",
3651            &LinterSettings::for_rule(Rule::SourceInsideFunctionInSh),
3652        );
3653        assert_eq!(diagnostics.len(), 1);
3654        assert_eq!(diagnostics[0].rule, Rule::SourceInsideFunctionInSh);
3655    }
3656
3657    #[test]
3658    fn top_level_source_is_not_flagged_by_x080() {
3659        let diagnostics = lint(
3660            "#!/bin/sh\nsource ./helpers.sh\n",
3661            &LinterSettings::for_rule(Rule::SourceInsideFunctionInSh),
3662        );
3663        assert!(diagnostics.is_empty());
3664    }
3665
3666    #[test]
3667    fn source_inside_function_in_bash_is_not_flagged_by_x080() {
3668        let diagnostics = lint(
3669            "#!/bin/bash\nf() {\n  source ./helpers.sh\n}\n",
3670            &LinterSettings::for_rule(Rule::SourceInsideFunctionInSh),
3671        );
3672        assert!(diagnostics.is_empty());
3673    }
3674
3675    #[test]
3676    fn let_command_in_sh_is_flagged() {
3677        let diagnostics = lint(
3678            "#!/bin/sh\nlet x=1\n",
3679            &LinterSettings::for_rule(Rule::LetCommand),
3680        );
3681        assert_eq!(diagnostics.len(), 1);
3682        assert_eq!(diagnostics[0].rule, Rule::LetCommand);
3683    }
3684
3685    #[test]
3686    fn let_command_in_dash_is_flagged() {
3687        let diagnostics = lint(
3688            "#!/bin/dash\nlet x=1\n",
3689            &LinterSettings::for_rule(Rule::LetCommand),
3690        );
3691        assert_eq!(diagnostics.len(), 1);
3692        assert_eq!(diagnostics[0].rule, Rule::LetCommand);
3693    }
3694
3695    #[test]
3696    fn let_command_in_bash_is_not_flagged_for_portability_rule() {
3697        let diagnostics = lint(
3698            "#!/bin/bash\nlet x=1\n",
3699            &LinterSettings::for_rule(Rule::LetCommand),
3700        );
3701        assert!(diagnostics.is_empty());
3702    }
3703
3704    #[test]
3705    fn declare_command_in_sh_is_flagged() {
3706        let diagnostics = lint(
3707            "#!/bin/sh\ndeclare foo=bar\n",
3708            &LinterSettings::for_rule(Rule::DeclareCommand),
3709        );
3710        assert_eq!(diagnostics.len(), 1);
3711        assert_eq!(diagnostics[0].rule, Rule::DeclareCommand);
3712    }
3713
3714    #[test]
3715    fn declare_command_in_dash_is_flagged() {
3716        let diagnostics = lint(
3717            "#!/bin/dash\ndeclare foo=bar\n",
3718            &LinterSettings::for_rule(Rule::DeclareCommand),
3719        );
3720        assert_eq!(diagnostics.len(), 1);
3721        assert_eq!(diagnostics[0].rule, Rule::DeclareCommand);
3722    }
3723
3724    #[test]
3725    fn typeset_command_in_sh_is_flagged() {
3726        let diagnostics = lint(
3727            "#!/bin/sh\ntypeset foo=bar\n",
3728            &LinterSettings::for_rule(Rule::DeclareCommand),
3729        );
3730        assert_eq!(diagnostics.len(), 1);
3731        assert_eq!(diagnostics[0].rule, Rule::DeclareCommand);
3732        assert_eq!(
3733            diagnostics[0].message,
3734            "`typeset` is not portable in `sh` scripts"
3735        );
3736    }
3737
3738    #[test]
3739    fn typeset_command_in_dash_is_flagged() {
3740        let diagnostics = lint(
3741            "#!/bin/dash\ntypeset foo=bar\n",
3742            &LinterSettings::for_rule(Rule::DeclareCommand),
3743        );
3744        assert_eq!(diagnostics.len(), 1);
3745        assert_eq!(diagnostics[0].rule, Rule::DeclareCommand);
3746        assert_eq!(
3747            diagnostics[0].message,
3748            "`typeset` is not portable in `sh` scripts"
3749        );
3750    }
3751
3752    #[test]
3753    fn shopt_command_in_sh_is_flagged() {
3754        let diagnostics = lint(
3755            "#!/bin/sh\nshopt -s nullglob\n",
3756            &LinterSettings::for_rule(Rule::DeclareCommand),
3757        );
3758        assert_eq!(diagnostics.len(), 1);
3759        assert_eq!(diagnostics[0].rule, Rule::DeclareCommand);
3760        assert_eq!(
3761            diagnostics[0].message,
3762            "`shopt` is not portable in `sh` scripts"
3763        );
3764    }
3765
3766    #[test]
3767    fn pushd_command_in_sh_is_flagged() {
3768        let diagnostics = lint(
3769            "#!/bin/sh\npushd /tmp\n",
3770            &LinterSettings::for_rule(Rule::DeclareCommand),
3771        );
3772        assert_eq!(diagnostics.len(), 1);
3773        assert_eq!(diagnostics[0].rule, Rule::DeclareCommand);
3774        assert_eq!(
3775            diagnostics[0].message,
3776            "`pushd` is not portable in `sh` scripts"
3777        );
3778    }
3779
3780    #[test]
3781    fn mapfile_command_in_sh_is_flagged() {
3782        let diagnostics = lint(
3783            "#!/bin/sh\nmapfile entries\n",
3784            &LinterSettings::for_rule(Rule::DeclareCommand),
3785        );
3786        assert_eq!(diagnostics.len(), 1);
3787        assert_eq!(diagnostics[0].rule, Rule::DeclareCommand);
3788        assert_eq!(
3789            diagnostics[0].message,
3790            "`mapfile` is not portable in `sh` scripts"
3791        );
3792    }
3793
3794    #[test]
3795    fn declare_command_in_bash_is_not_flagged_for_portability_rule() {
3796        let diagnostics = lint(
3797            "#!/bin/bash\ndeclare foo=bar\n",
3798            &LinterSettings::for_rule(Rule::DeclareCommand),
3799        );
3800        assert!(diagnostics.is_empty());
3801    }
3802
3803    #[test]
3804    fn typeset_command_in_bash_is_not_flagged_for_portability_rule() {
3805        let diagnostics = lint(
3806            "#!/bin/bash\ntypeset foo=bar\n",
3807            &LinterSettings::for_rule(Rule::DeclareCommand),
3808        );
3809        assert!(diagnostics.is_empty());
3810    }
3811
3812    #[test]
3813    fn shopt_command_in_bash_is_not_flagged_for_portability_rule() {
3814        let diagnostics = lint(
3815            "#!/bin/bash\nshopt -s nullglob\n",
3816            &LinterSettings::for_rule(Rule::DeclareCommand),
3817        );
3818        assert!(diagnostics.is_empty());
3819    }
3820
3821    #[test]
3822    fn multiline_declare_command_is_clipped_to_opening_line() {
3823        let source = "#!/bin/sh\ndeclare -a values=(\n  one\n  two\n)\n";
3824        let diagnostics = lint(source, &LinterSettings::for_rule(Rule::DeclareCommand));
3825        assert_eq!(diagnostics.len(), 1);
3826        assert_eq!(diagnostics[0].span.slice(source), "declare -a values");
3827        assert_eq!(diagnostics[0].span.end.line, 2);
3828    }
3829
3830    #[test]
3831    fn source_builtin_in_sh_is_flagged() {
3832        let diagnostics = lint(
3833            "#!/bin/sh\nsource ./helpers.sh\n",
3834            &LinterSettings::for_rule(Rule::SourceBuiltinInSh),
3835        );
3836        assert_eq!(diagnostics.len(), 1);
3837        assert_eq!(diagnostics[0].rule, Rule::SourceBuiltinInSh);
3838    }
3839
3840    #[test]
3841    fn source_builtin_in_dash_is_flagged() {
3842        let diagnostics = lint(
3843            "#!/bin/dash\nsource ./helpers.sh\n",
3844            &LinterSettings::for_rule(Rule::SourceBuiltinInSh),
3845        );
3846        assert_eq!(diagnostics.len(), 1);
3847        assert_eq!(diagnostics[0].rule, Rule::SourceBuiltinInSh);
3848    }
3849
3850    #[test]
3851    fn source_builtin_in_bash_is_not_flagged_for_portability_rule() {
3852        let diagnostics = lint(
3853            "#!/bin/bash\nsource ./helpers.sh\n",
3854            &LinterSettings::for_rule(Rule::SourceBuiltinInSh),
3855        );
3856        assert!(diagnostics.is_empty());
3857    }
3858
3859    #[test]
3860    fn source_builtin_in_command_substitution_is_flagged() {
3861        let diagnostics = lint(
3862            "#!/bin/sh\nversion=$(source ./helpers.sh)\n",
3863            &LinterSettings::for_rule(Rule::SourceBuiltinInSh),
3864        );
3865        assert_eq!(diagnostics.len(), 1);
3866        assert_eq!(diagnostics[0].rule, Rule::SourceBuiltinInSh);
3867    }
3868
3869    #[test]
3870    fn source_builtin_inside_function_is_flagged_by_x031() {
3871        let diagnostics = lint(
3872            "#!/bin/sh\nload() {\n  source ./helpers.sh\n}\n",
3873            &LinterSettings::for_rule(Rule::SourceBuiltinInSh),
3874        );
3875        assert_eq!(diagnostics.len(), 1);
3876        assert_eq!(diagnostics[0].rule, Rule::SourceBuiltinInSh);
3877    }
3878
3879    #[test]
3880    fn exported_variable_not_flagged() {
3881        let diagnostics = lint_for_rule("#!/bin/sh\nexport FOO=1\n", Rule::UnusedAssignment);
3882        assert!(diagnostics.is_empty());
3883    }
3884
3885    #[test]
3886    fn branch_assignments_followed_by_a_read_are_not_flagged() {
3887        let diagnostics = lint(
3888            "\
3889#!/bin/sh
3890if command -v code >/dev/null 2>&1; then
3891  code_command=\"code\"
3892else
3893  code_command=\"flatpak run com.visualstudio.code\"
3894fi
3895${code_command} --version
3896",
3897            &LinterSettings::for_rule(Rule::UnusedAssignment),
3898        );
3899
3900        assert!(diagnostics.is_empty());
3901    }
3902
3903    #[test]
3904    fn mutually_exclusive_unused_branch_assignments_report_one_diagnostic() {
3905        let source = "\
3906#!/bin/sh
3907if command -v code >/dev/null 2>&1; then
3908  code_command=\"code\"
3909else
3910  code_command=\"flatpak run com.visualstudio.code\"
3911fi
3912";
3913        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
3914
3915        assert_eq!(diagnostics.len(), 1);
3916        assert_eq!(diagnostics[0].span.start.line, 5);
3917    }
3918
3919    #[test]
3920    fn branch_local_reads_suppress_unused_assignment_family() {
3921        let source = "\
3922#!/bin/sh
3923if a; then
3924  VAR=1
3925elif b; then
3926  VAR=2
3927else
3928  VAR=3
3929  echo \"$VAR\"
3930fi
3931";
3932        let diagnostics = lint_for_rule(source, Rule::UnusedAssignment);
3933
3934        assert!(diagnostics.is_empty());
3935    }
3936
3937    #[test]
3938    fn case_branch_assignments_used_in_function_body_are_not_flagged() {
3939        let diagnostics = lint(
3940            "\
3941#!/bin/sh
3942case \"$arch\" in
3943amd64 | x86_64)
3944  jq_arch=amd64
3945  core_arch=64
3946  ;;
3947arm64 | aarch64)
3948  jq_arch=arm64
3949  core_arch=arm64-v8a
3950  ;;
3951esac
3952download() {
3953  echo \"$jq_arch\"
3954  echo \"$core_arch\"
3955}
3956",
3957            &LinterSettings::for_rule(Rule::UnusedAssignment),
3958        );
3959
3960        assert!(diagnostics.is_empty());
3961    }
3962
3963    #[test]
3964    fn case_without_matching_arm_keeps_initializer_live() {
3965        let source = "\
3966#!/bin/sh
3967value=''
3968case \"$kind\" in
3969  one)
3970    value=1
3971    ;;
3972  two)
3973    value=2
3974    ;;
3975esac
3976printf '%s\\n' \"$value\"
3977";
3978        let diagnostics = lint_named_source_with_parse_dialect(
3979            Path::new("/tmp/case-no-match.sh"),
3980            source,
3981            ParseDialect::Posix,
3982            &LinterSettings::for_rule(Rule::UnusedAssignment),
3983        );
3984
3985        assert!(diagnostics.is_empty());
3986    }
3987
3988    #[test]
3989    fn function_global_assignments_read_later_by_caller_are_not_flagged() {
3990        let diagnostics = lint(
3991            "\
3992#!/bin/sh
3993pass_args() {
3994  local_install=1
3995  proxy=$1
3996}
3997main() {
3998  pass_args \"$@\"
3999  printf '%s %s\\n' \"$local_install\" \"$proxy\"
4000}
4001main \"$@\"
4002",
4003            &LinterSettings::for_rule(Rule::UnusedAssignment),
4004        );
4005
4006        assert!(diagnostics.is_empty());
4007    }
4008
4009    #[test]
4010    fn recursive_function_state_assignment_is_not_flagged() {
4011        let diagnostics = lint(
4012            "\
4013#!/bin/bash
4014check_status() {
4015  if [[ $is_wget ]]; then
4016    printf '%s\\n' ok
4017  else
4018    is_wget=1
4019    check_status
4020  fi
4021}
4022check_status
4023",
4024            &LinterSettings::for_rule(Rule::UnusedAssignment),
4025        );
4026
4027        assert!(diagnostics.is_empty());
4028    }
4029
4030    #[test]
4031    fn unused_function_global_assignment_is_still_flagged() {
4032        let diagnostics = lint(
4033            "\
4034#!/bin/sh
4035f() {
4036  foo=1
4037}
4038f
4039",
4040            &LinterSettings::for_rule(Rule::UnusedAssignment),
4041        );
4042
4043        assert_eq!(diagnostics.len(), 1);
4044        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
4045        assert_eq!(
4046            diagnostics[0]
4047                .span
4048                .slice("#!/bin/sh\nf() {\n  foo=1\n}\nf\n"),
4049            "foo"
4050        );
4051    }
4052
4053    #[test]
4054    fn name_only_local_declaration_read_is_not_reported_as_uninitialized() {
4055        let diagnostics = lint_for_rule(
4056            "\
4057#!/bin/bash
4058f() {
4059  local foo
4060  printf '%s\\n' \"$foo\"
4061}
4062f
4063",
4064            Rule::UndefinedVariable,
4065        );
4066
4067        assert!(diagnostics.is_empty());
4068    }
4069
4070    #[test]
4071    fn resolved_indirect_expansion_carrier_is_not_reported_as_uninitialized() {
4072        let diagnostics = lint_for_rule(
4073            "\
4074#!/bin/bash
4075f() {
4076  local foo
4077  printf '%s\\n' \"${!foo}\"
4078}
4079f
4080",
4081            Rule::UndefinedVariable,
4082        );
4083
4084        assert!(diagnostics.is_empty());
4085    }
4086
4087    #[test]
4088    fn indirect_reads_do_not_report_missing_targets_for_indirect_or_nameref_access() {
4089        let diagnostics = lint_for_rule(
4090            "\
4091#!/bin/bash
4092name=missing
4093declare -n ref=missing
4094printf '%s %s\\n' \"${!name}\" \"$ref\"
4095",
4096            Rule::UndefinedVariable,
4097        );
4098
4099        assert!(diagnostics.is_empty());
4100    }
4101
4102    #[test]
4103    fn unresolved_indirect_expansion_carrier_is_still_reported() {
4104        let source = "\
4105#!/bin/bash
4106printf '%s\\n' \"${!foo}\"
4107";
4108        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4109
4110        assert_eq!(diagnostics.len(), 1);
4111        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
4112        assert!(diagnostics[0].message.contains("foo"));
4113        assert_eq!(diagnostics[0].span.start.line, 2);
4114        assert_eq!(diagnostics[0].span.start.column, 16);
4115    }
4116
4117    #[test]
4118    fn unresolved_indirect_expansion_with_subscript_reports_carrier_only() {
4119        let source = "\
4120#!/bin/bash
4121printf '%s\\n' \"${!tools[$target]}\"
4122";
4123        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4124
4125        assert_eq!(diagnostics.len(), 1);
4126        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
4127        assert!(diagnostics[0].message.contains("tools"));
4128        assert!(!diagnostics[0].message.contains("target"));
4129        assert_eq!(diagnostics[0].span.start.line, 2);
4130        assert_eq!(diagnostics[0].span.start.column, 16);
4131    }
4132
4133    #[test]
4134    fn unresolved_indirect_replacement_reports_carrier_only() {
4135        let source = "\
4136#!/bin/bash
4137printf '%s\\n' \"${!var//$'\\n'/' '}\"
4138";
4139        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4140
4141        assert_eq!(diagnostics.len(), 1);
4142        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
4143        assert!(diagnostics[0].message.contains("var"));
4144        assert!(!diagnostics[0].message.contains("!var//"));
4145    }
4146
4147    #[test]
4148    fn indirect_special_parameter_carrier_is_not_reported() {
4149        let diagnostics = lint_for_rule(
4150            "\
4151#!/bin/bash
4152set -- last
4153printf '%s\\n' \"${!#}\"
4154",
4155            Rule::UndefinedVariable,
4156        );
4157
4158        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
4159    }
4160
4161    #[test]
4162    fn special_hash_parameter_operations_are_not_reported() {
4163        let diagnostics = lint_for_rule(
4164            "\
4165#!/bin/bash
4166printf '%s\\n' \"${##*/}\"
4167",
4168            Rule::UndefinedVariable,
4169        );
4170
4171        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
4172    }
4173
4174    #[test]
4175    fn special_zero_prefix_removal_inside_escaped_quotes_is_not_reported() {
4176        let diagnostics = lint_for_rule(
4177            "\
4178#!/bin/bash
4179usage=\"
4180Terraform:
4181
4182    data \\\"external\\\" \\\"github_repos\\\" {
4183        program = [\\\"/path/to/${0##*/}\\\", \\\"github_repository\\\"]
4184    }
4185\"
4186",
4187            Rule::UndefinedVariable,
4188        );
4189
4190        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
4191    }
4192
4193    #[test]
4194    fn undefined_variable_anchors_parameter_operator_reports_to_carrier_name() {
4195        let source = "\
4196#!/bin/bash
4197printf '%s\\n' \"${missing%%/*}\"
4198";
4199        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4200
4201        assert_eq!(diagnostics.len(), 1);
4202        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
4203        assert!(diagnostics[0].message.contains("missing"));
4204        assert_eq!(diagnostics[0].span.start.line, 2);
4205        assert_eq!(diagnostics[0].span.start.column, 16);
4206    }
4207
4208    #[test]
4209    fn undefined_variable_anchors_escaped_quote_parameter_expansions_to_the_parameter() {
4210        let source = "\
4211#!/bin/bash
4212rvm_info=\"
4213  uname: \\\"${_system_info}\\\"
4214\"
4215";
4216        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4217
4218        assert_eq!(diagnostics.len(), 1);
4219        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
4220        assert!(diagnostics[0].message.contains("_system_info"));
4221        assert_eq!(diagnostics[0].span.start.line, 3);
4222        assert_eq!(diagnostics[0].span.start.column, 12);
4223        assert_eq!(diagnostics[0].span.slice(source), "${_system_info}");
4224    }
4225
4226    #[test]
4227    fn undefined_variable_anchors_multiline_escaped_quote_parameter_expansions_to_the_parameter() {
4228        let source = "\
4229#!/bin/bash
4230payload=\"{
4231\t\\\"client_id\\\": \\\"${uuidinstance}\\\",
4232\t\\\"events\\\": [
4233\t\t{
4234\t\t\\\"name\\\": \\\"LinuxGSM\\\",
4235\t\t\\\"params\\\": {
4236\t\t\t\\\"cpuusedmhzroundup\\\": \\\"${cpuusedmhzroundup}\\\",
4237\t\t\t\\\"diskused\\\": \\\"${serverfilesdu}\\\",
4238\t\t\t}
4239\t\t}
4240\t]
4241}\"
4242";
4243        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4244        let diagnostic = diagnostics
4245            .iter()
4246            .find(|diagnostic| diagnostic.message.contains("serverfilesdu"))
4247            .unwrap();
4248
4249        assert_eq!(diagnostic.span.start.line, 9);
4250        assert_eq!(diagnostic.span.start.column, 20);
4251        assert_eq!(diagnostic.span.slice(source), "${serverfilesdu}");
4252    }
4253
4254    #[test]
4255    fn undefined_variable_anchors_unbraced_references_after_escaped_quotes() {
4256        let source = "\
4257#!/bin/bash
4258rvm_info=\"
4259  path:         \\\"$rvm_path\\\"
4260\"
4261addtimestamp=\"gawk '{ print strftime(\\\\\\\"[$logtimestampformat]\\\\\\\"), \\\\\\$0 }'\"
4262";
4263        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4264
4265        let spans = diagnostics
4266            .iter()
4267            .map(|diagnostic| diagnostic.span.slice(source))
4268            .collect::<Vec<_>>();
4269        assert_eq!(spans, vec!["$rvm_path", "$logtimestampformat"]);
4270    }
4271
4272    #[test]
4273    fn undefined_variable_ignores_self_referential_assignments() {
4274        let diagnostics = lint_for_rule(
4275            "\
4276#!/bin/sh
4277foo=\"$foo\"
4278for flag in a b; do
4279  valid_flags=\"${valid_flags} $flag\"
4280done
4281",
4282            Rule::UndefinedVariable,
4283        );
4284
4285        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
4286    }
4287
4288    #[test]
4289    fn undefined_variable_ignores_escaped_declaration_dynamic_assignments() {
4290        let diagnostics = lint_for_rule(
4291            "\
4292#!/bin/bash
4293\\typeset ret=$?
4294echo \"$ret\"
4295",
4296            Rule::UndefinedVariable,
4297        );
4298
4299        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
4300    }
4301
4302    #[test]
4303    fn undefined_variable_reports_arithmetic_conditional_literal_operands() {
4304        let source = "\
4305#!/bin/bash
4306version=1
4307if [[ $version -eq \"latest\" ]]; then
4308  :
4309fi
4310";
4311        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4312
4313        assert_eq!(diagnostics.len(), 1);
4314        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
4315        assert!(diagnostics[0].message.contains("latest"));
4316        assert_eq!(diagnostics[0].span.slice(source), "\"latest\"");
4317    }
4318
4319    #[test]
4320    fn undefined_variable_ignores_let_arithmetic_assignment_targets() {
4321        let source = "\
4322#!/bin/bash
4323let line=\"$number\"+1
4324printf '%s\\n' \"$line\"
4325";
4326        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4327
4328        assert_eq!(diagnostics.len(), 1);
4329        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
4330        assert_eq!(diagnostics[0].span.slice(source), "$number");
4331    }
4332
4333    #[test]
4334    fn undefined_variable_ignores_assignment_values_after_escaped_newlines() {
4335        let diagnostics = lint_for_rule(
4336            "\
4337#!/bin/sh
4338easyrsa_ksh=\\
4339'value'
4340[ \"${KSH_VERSION}\" = \"${easyrsa_ksh}\" ] && echo ok
4341",
4342            Rule::UndefinedVariable,
4343        );
4344
4345        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
4346    }
4347
4348    #[test]
4349    fn undefined_variable_ignores_backtick_double_escaped_echo_templates() {
4350        let source = "\
4351#!/bin/bash
4352XDGPATH=`echo \"foreach dir [split [::tcl::tm::path list]] {puts \\\\$dir}\" | tclsh | tail -n1`
4353printf '%s\\n' \"$missing\"
4354";
4355        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4356
4357        assert_eq!(diagnostics.len(), 1);
4358        assert_eq!(diagnostics[0].span.slice(source), "$missing");
4359    }
4360
4361    #[test]
4362    fn undefined_variable_reports_unparsed_indexed_subscript_prefixes() {
4363        let source = "\
4364#!/bin/bash
4365arr+=([docker:dind]=x [nats-streaming:nanoserver]=y)
4366";
4367        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4368
4369        assert_eq!(
4370            diagnostics
4371                .iter()
4372                .map(|diagnostic| diagnostic.span.slice(source))
4373                .collect::<Vec<_>>(),
4374            vec!["docker", "nats", "streaming"]
4375        );
4376    }
4377
4378    #[test]
4379    fn undefined_variable_skips_parameter_replacement_pattern_reads() {
4380        let source = "\
4381#!/bin/bash
4382dir=all/retroarch.cfg
4383echo \"${dir//$configdir\\/}\"
4384find \"$configdir\"
4385";
4386        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4387
4388        assert_eq!(
4389            diagnostics
4390                .iter()
4391                .map(|diagnostic| diagnostic.span.slice(source))
4392                .collect::<Vec<_>>(),
4393            vec!["$configdir"]
4394        );
4395    }
4396
4397    #[test]
4398    fn undefined_variable_reports_plain_reads_before_parameter_patterns() {
4399        let source = "\
4400#!/bin/bash
4401dir=all/retroarch.cfg
4402find \"$configdir\"
4403echo \"${dir//$configdir\\/}\"
4404";
4405        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4406
4407        assert_eq!(
4408            diagnostics
4409                .iter()
4410                .map(|diagnostic| diagnostic.span.slice(source))
4411                .collect::<Vec<_>>(),
4412            vec!["$configdir"]
4413        );
4414    }
4415
4416    #[test]
4417    fn undefined_variable_reports_redirect_target_references() {
4418        let source = "\
4419#!/bin/bash
4420{ echo value; } >> \"${missing_target}/out\"
4421echo \"${ordinary_missing}/out\"
4422";
4423        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4424
4425        assert_eq!(
4426            diagnostics
4427                .iter()
4428                .map(|diagnostic| diagnostic.span.slice(source))
4429                .collect::<Vec<_>>(),
4430            vec!["${missing_target}", "${ordinary_missing}"]
4431        );
4432    }
4433
4434    #[test]
4435    fn undefined_variable_reports_unreachable_references() {
4436        let source = "\
4437#!/bin/bash
4438load_value() {
4439  return 1
4440  printf '%s\\n' \"$after_return\"
4441}
4442load_value
4443";
4444        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4445
4446        assert_eq!(
4447            diagnostics
4448                .iter()
4449                .map(|diagnostic| diagnostic.span.slice(source))
4450                .collect::<Vec<_>>(),
4451            vec!["$after_return"]
4452        );
4453    }
4454
4455    #[test]
4456    fn undefined_variable_ignores_bound_name_between_escaped_quote_literals() {
4457        let diagnostics = lint_for_rule(
4458            "\
4459#!/bin/sh
4460archname=archive
4461echo Self-extractable archive \\\"$archname\\\" successfully created.
4462",
4463            Rule::UndefinedVariable,
4464        );
4465
4466        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
4467    }
4468
4469    #[test]
4470    fn unquoted_heredoc_generated_shell_text_reports_c006() {
4471        let diagnostics = lint_for_rule(
4472            "\
4473archname=archive
4474cat <<EOF > \"$archname\"
4475#!/bin/sh
4476ORIG_UMASK=`umask`
4477if test \"$KEEP_UMASK\" = n; then
4478    umask 077
4479fi
4480
4481CRCsum=\"$CRCsum\"
4482archdirname=\"$archdirname\"
4483EOF
4484",
4485            Rule::UndefinedVariable,
4486        );
4487
4488        assert_eq!(diagnostics.len(), 2);
4489        assert!(
4490            diagnostics
4491                .iter()
4492                .any(|diagnostic| diagnostic.message.contains("CRCsum"))
4493        );
4494        assert!(
4495            diagnostics
4496                .iter()
4497                .any(|diagnostic| diagnostic.message.contains("archdirname"))
4498        );
4499    }
4500
4501    #[test]
4502    fn escaped_heredoc_parameter_literals_report_nested_references() {
4503        let source = "\
4504#!/bin/bash
4505cat <<EOF
4506\\${OUTER:-$inner}
4507EOF
4508";
4509        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4510
4511        assert_eq!(diagnostics.len(), 1);
4512        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
4513        assert!(diagnostics[0].message.contains("inner"));
4514        assert_eq!(diagnostics[0].span.slice(source), "$inner");
4515    }
4516
4517    #[test]
4518    fn undefined_variable_reports_bash_fallback_after_zsh_split_branch() {
4519        let source = "\
4520#!/bin/bash
4521if [[ -n \"${ZSH_VERSION:-}\" ]]; then
4522  rvm_configure_flags=( ${=db_configure_flags} \"${rvm_configure_flags[@]}\" )
4523else
4524  rvm_configure_flags=( ${db_configure_flags} \"${rvm_configure_flags[@]}\" )
4525fi
4526";
4527        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4528
4529        assert_eq!(diagnostics.len(), 1);
4530        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
4531        assert_eq!(diagnostics[0].span.start.line, 5);
4532        assert_eq!(diagnostics[0].span.slice(source), "${db_configure_flags}");
4533    }
4534
4535    #[test]
4536    fn undefined_variable_ignores_parameter_slice_arithmetic_operands() {
4537        let source = "\
4538#!/bin/bash
4539value=abcdef
4540printf '%s\\n' \"${value:offset}\" \"${value:1:$length}\"
4541";
4542        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4543
4544        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
4545    }
4546
4547    #[test]
4548    fn undefined_variable_ignores_names_bound_anywhere_in_the_file() {
4549        let source = "\
4550#!/bin/bash
4551echo \"$missing\"
4552if true; then
4553  maybe=1
4554fi
4555echo \"$maybe\"
4556echo \"$late\"
4557late=1
4558helper() {
4559  printf '%s\\n' \"$package\" \"$seeded_elsewhere\"
4560}
4561seed() {
4562  local seeded_elsewhere=1
4563}
4564package=readline
4565helper
4566";
4567        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4568
4569        assert_eq!(diagnostics.len(), 1);
4570        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
4571        assert!(diagnostics[0].message.contains("missing"));
4572        assert!(
4573            diagnostics[0]
4574                .message
4575                .contains("referenced before assignment")
4576        );
4577    }
4578
4579    #[test]
4580    fn undefined_variable_ignores_same_declaration_command_bindings() {
4581        let diagnostics = lint_for_rule(
4582            "\
4583#!/bin/bash
4584f() {
4585  local first=1 second=\"$first\"
4586  local later=\"$after\" after=1
4587}
4588f
4589",
4590            Rule::UndefinedVariable,
4591        );
4592
4593        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
4594    }
4595
4596    #[test]
4597    fn undefined_variable_reports_only_first_reportable_use_per_name() {
4598        let source = "\
4599#!/bin/bash
4600helper() {
4601  printf '%s %s\\n' \"$missing\" \"$also_missing\"
4602}
4603printf '%s\\n' \"$missing\"
4604printf '%s\\n' \"$also_missing\"
4605helper
4606printf '%s %s\\n' \"$missing\" \"$also_missing\"
4607";
4608        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4609
4610        assert_eq!(diagnostics.len(), 2);
4611        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
4612        assert!(diagnostics[0].message.contains("missing"));
4613        assert_eq!(diagnostics[0].span.start.line, 3);
4614        assert_eq!(diagnostics[1].rule, Rule::UndefinedVariable);
4615        assert!(diagnostics[1].message.contains("also_missing"));
4616        assert_eq!(diagnostics[1].span.start.line, 3);
4617    }
4618
4619    #[test]
4620    fn undefined_variable_parameter_guard_flow_respects_same_command_order() {
4621        let source = "\
4622#!/bin/sh
4623printf '%s\\n' \"$before_default\" \"${before_default:-fallback}\"
4624printf '%s\\n' \"${guarded:-fallback}\" \"$guarded\"
4625printf '%s\\n' \"$plain_missing\"
4626";
4627        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4628
4629        assert_eq!(diagnostics.len(), 2);
4630        assert!(
4631            diagnostics
4632                .iter()
4633                .any(|diagnostic| diagnostic.message.contains("before_default")
4634                    && diagnostic.span.start.line == 2)
4635        );
4636        assert!(
4637            diagnostics
4638                .iter()
4639                .any(|diagnostic| diagnostic.message.contains("plain_missing")
4640                    && diagnostic.span.start.line == 4)
4641        );
4642        assert!(
4643            diagnostics
4644                .iter()
4645                .all(|diagnostic| !diagnostic.message.contains("guarded"))
4646        );
4647    }
4648
4649    #[test]
4650    fn undefined_variable_ignores_declaration_names_and_special_parameters() {
4651        let diagnostics = lint_for_rule(
4652            "\
4653#!/bin/bash
4654readonly declared
4655export exported
4656printf '%s %s %s\\n' \"$1\" \"$@\" \"$#\"
4657printf '%s %s\\n' \"${#}\" \"${$}\"
4658",
4659            Rule::UndefinedVariable,
4660        );
4661
4662        assert!(diagnostics.is_empty());
4663    }
4664
4665    #[test]
4666    fn undefined_variable_ignores_bash_runtime_vars_in_bash_scripts() {
4667        let source = runtime_prelude_source("#!/bin/bash");
4668        let diagnostics = lint_for_rule(&source, Rule::UndefinedVariable);
4669
4670        assert!(diagnostics.is_empty());
4671    }
4672
4673    #[test]
4674    fn undefined_variable_ignores_zsh_runtime_vars_in_zsh_scripts() {
4675        let source = zsh_runtime_prelude_source("#!/bin/zsh");
4676        let diagnostics = lint_for_rule(&source, Rule::UndefinedVariable);
4677
4678        assert!(diagnostics.is_empty());
4679    }
4680
4681    #[test]
4682    fn undefined_variable_ignores_environment_style_names() {
4683        let source = "\
4684#!/bin/sh
4685printf '%s %s %s %s %s %s %s\\n' \
4686  \"$FOO\" \
4687  \"$PATH\" \
4688  \"$UID\" \
4689  \"$XDG_CONFIG_HOME\" \
4690  \"$OPTARG\" \
4691  \"$OPTIND\" \
4692  \"$__FOO\"
4693printf '%s %s\\n' \"$foo\" \"$Foo_BAR\"
4694";
4695        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4696
4697        assert_eq!(diagnostics.len(), 2);
4698        assert!(
4699            diagnostics
4700                .iter()
4701                .any(|diagnostic| diagnostic.message.contains("foo"))
4702        );
4703        assert!(
4704            diagnostics
4705                .iter()
4706                .any(|diagnostic| diagnostic.message.contains("Foo_BAR"))
4707        );
4708    }
4709
4710    #[test]
4711    fn undefined_variable_can_report_environment_style_names_when_requested() {
4712        let source = "\
4713#!/bin/sh
4714printf '%s %s\\n' \"$FOO\" \"$XDG_CONFIG_HOME\"
4715";
4716        let diagnostics = lint(
4717            source,
4718            &LinterSettings {
4719                rules: RuleSet::from_iter([Rule::UndefinedVariable]),
4720                report_environment_style_names: true,
4721                ..LinterSettings::default()
4722            },
4723        );
4724
4725        assert_eq!(diagnostics.len(), 2);
4726        assert!(
4727            diagnostics
4728                .iter()
4729                .any(|diagnostic| diagnostic.message.contains("FOO"))
4730        );
4731        assert!(
4732            diagnostics
4733                .iter()
4734                .any(|diagnostic| diagnostic.message.contains("XDG_CONFIG_HOME"))
4735        );
4736    }
4737
4738    #[test]
4739    fn undefined_variable_ignores_guarded_parameter_expansions() {
4740        let source = "\
4741#!/bin/sh
4742printf '%s %s %s %s\\n' \
4743  \"${missing_default:-fallback}\" \
4744  \"${missing_assign:=value}\" \
4745  \"${missing_replace:+alt}\" \
4746  \"${missing_error:?missing}\"
4747printf '%s %s %s %s %s\\n' \
4748  \"${missing_default:-$fallback_name}\" \
4749  \"${missing_assign:=${seed_name:-value}}\" \
4750  \"${missing_replace:+$replacement_name}\" \
4751  \"${missing_error:?$hint_name}\" \
4752  \"$missing_assign\"
4753printf '%s\\n' \"$fallback_name\" \"$seed_name\" \"$replacement_name\" \"$hint_name\" \"$plain_missing\"
4754";
4755        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4756
4757        assert_eq!(diagnostics.len(), 1);
4758        assert!(
4759            diagnostics
4760                .iter()
4761                .all(|d| d.rule == Rule::UndefinedVariable)
4762        );
4763        assert!(
4764            diagnostics
4765                .iter()
4766                .any(|d| d.message.contains("plain_missing"))
4767        );
4768    }
4769
4770    #[test]
4771    fn undefined_variable_ignores_associative_subscript_literals() {
4772        let source = "\
4773#!/bin/bash
4774declare -A map
4775map[swift-cmark]=1
4776printf '%s %s\\n' \"${map[swift-cmark]}\" \"${map[$dynamic_key]}\"
4777";
4778        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4779
4780        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
4781    }
4782
4783    #[test]
4784    fn undefined_variable_suppresses_later_subscript_uses_after_read_subscripts() {
4785        let source = "\
4786#!/bin/bash
4787declare -a args
4788declare -A tools
4789printf '%s\\n' \"${args[$__array_start]}\"
4790args[$__array_start]=ok
4791unset args[$unset_index]
4792printf '%s\\n' \"${tools[$target]}\"
4793tools[$target]=ok
4794printf '%s\\n' \"$__array_start\" \"$target\"
4795";
4796        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4797
4798        assert_eq!(
4799            diagnostics
4800                .iter()
4801                .map(|diagnostic| diagnostic.span.slice(source))
4802                .collect::<Vec<_>>(),
4803            Vec::<&str>::new()
4804        );
4805    }
4806
4807    #[test]
4808    fn undefined_variable_reports_unseen_plain_uses_after_subscript_only_uses() {
4809        let source = "\
4810#!/bin/bash
4811declare -a args
4812declare -A tools
4813printf '%s %s\\n' \"${args[$idx]}\" \"${tools[$target]}\"
4814printf '%s %s %s\\n' \"$idx\" \"$target\" \"$unseen\"
4815";
4816        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4817
4818        assert_eq!(
4819            diagnostics
4820                .iter()
4821                .map(|diagnostic| diagnostic.span.slice(source))
4822                .collect::<Vec<_>>(),
4823            vec!["$unseen"]
4824        );
4825    }
4826
4827    #[test]
4828    fn undefined_variable_ignores_presence_tested_names_in_supported_guards() {
4829        let source = "\
4830#!/bin/bash
4831[ -z \"$guarded\" ] && echo nope
4832[ \"$truthy\" ] && echo maybe
4833[ -v simple_v ] && echo set
4834test -v test_v && echo set
4835[ -z \"$chain_left\" -a -z \"$chain_right\" ] && echo both
4836[ \"$or_left\" -o \"$or_right\" ] && echo either
4837if [[ -n \"${nonempty:-}\" && \"$also_truthy\" ]]; then
4838  echo yes
4839fi
4840if [[ -v conditional_v ]]; then
4841  echo set
4842fi
4843if [[ ! -v conditional_not_v ]]; then
4844  echo unset
4845fi
4846if [ \"$eq_mix\" = x -a -z \"$guard_after_eq\" ]; then
4847  echo no
4848fi
4849if [[ \"$eq_only\" = x ]]; then
4850  echo no
4851fi
4852if [[ -s \"$file_only\" ]]; then
4853  echo no
4854fi
4855echo \"$guarded\" \"$truthy\" \"$simple_v\" \"$test_v\" \"$chain_left\" \"$chain_right\" \"$or_left\" \"$or_right\" \"$nonempty\" \"$also_truthy\" \"$conditional_v\" \"$conditional_not_v\" \"$eq_mix\" \"$guard_after_eq\" \"$eq_only\" \"$file_only\" \"$still_missing\"
4856";
4857        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4858
4859        assert!(
4860            diagnostics
4861                .iter()
4862                .all(|diagnostic| !diagnostic.message.contains("guarded"))
4863        );
4864        assert!(
4865            diagnostics
4866                .iter()
4867                .all(|diagnostic| !diagnostic.message.contains("truthy"))
4868        );
4869        assert!(
4870            diagnostics
4871                .iter()
4872                .all(|diagnostic| !diagnostic.message.contains("simple_v"))
4873        );
4874        assert!(
4875            diagnostics
4876                .iter()
4877                .any(|diagnostic| diagnostic.message.contains("test_v"))
4878        );
4879        assert!(
4880            diagnostics
4881                .iter()
4882                .all(|diagnostic| !diagnostic.message.contains("chain_left"))
4883        );
4884        assert!(
4885            diagnostics
4886                .iter()
4887                .all(|diagnostic| !diagnostic.message.contains("chain_right"))
4888        );
4889        assert!(
4890            diagnostics
4891                .iter()
4892                .all(|diagnostic| !diagnostic.message.contains("or_left"))
4893        );
4894        assert!(
4895            diagnostics
4896                .iter()
4897                .all(|diagnostic| !diagnostic.message.contains("or_right"))
4898        );
4899        assert!(
4900            diagnostics
4901                .iter()
4902                .all(|diagnostic| !diagnostic.message.contains("nonempty"))
4903        );
4904        assert!(
4905            diagnostics
4906                .iter()
4907                .all(|diagnostic| !diagnostic.message.contains("also_truthy"))
4908        );
4909        assert!(
4910            diagnostics
4911                .iter()
4912                .all(|diagnostic| !diagnostic.message.contains("conditional_v"))
4913        );
4914        assert!(
4915            diagnostics
4916                .iter()
4917                .all(|diagnostic| !diagnostic.message.contains("conditional_not_v"))
4918        );
4919        assert!(
4920            diagnostics
4921                .iter()
4922                .all(|diagnostic| !diagnostic.message.contains("guard_after_eq"))
4923        );
4924        assert!(
4925            diagnostics
4926                .iter()
4927                .any(|diagnostic| diagnostic.message.contains("eq_mix"))
4928        );
4929        assert!(
4930            diagnostics
4931                .iter()
4932                .any(|diagnostic| diagnostic.message.contains("eq_only"))
4933        );
4934        assert!(
4935            diagnostics
4936                .iter()
4937                .any(|diagnostic| diagnostic.message.contains("file_only"))
4938        );
4939        assert!(
4940            diagnostics
4941                .iter()
4942                .any(|diagnostic| diagnostic.message.contains("still_missing"))
4943        );
4944    }
4945
4946    #[test]
4947    fn undefined_variable_reports_plain_test_command_presence_reads() {
4948        let source = "\
4949#!/bin/bash
4950test -n \"$plain_test\" && echo present
4951[ -n \"$bracket_test\" ] && echo present
4952if [[ -n \"$conditional_test\" ]]; then
4953  echo present
4954fi
4955echo \"$plain_test\" \"$bracket_test\" \"$conditional_test\"
4956";
4957        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4958
4959        assert_eq!(
4960            diagnostics
4961                .iter()
4962                .map(|diagnostic| diagnostic.span.slice(source))
4963                .collect::<Vec<_>>(),
4964            vec!["$plain_test"]
4965        );
4966    }
4967
4968    #[test]
4969    fn undefined_variable_nested_word_guards_do_not_suppress_plain_uses() {
4970        let source = "\
4971#!/bin/bash
4972printf '%s\\n' \"${fallback:-$([ \"$missing\" ])}\"
4973printf '%s\\n' \"$missing\"
4974";
4975        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4976
4977        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
4978    }
4979
4980    #[test]
4981    fn undefined_variable_keeps_nested_word_guard_suppression_inside_same_substitution() {
4982        let source = "\
4983#!/bin/bash
4984printf '%s\\n' \"$([ -n \"$missing\" ] && printf '%s' \"$missing\")\"
4985";
4986        let diagnostics = lint_for_rule(source, Rule::UndefinedVariable);
4987
4988        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
4989    }
4990
4991    #[test]
4992    fn unread_name_only_declarations_are_flagged() {
4993        let source = "\
4994#!/bin/bash
4995f() {
4996  local foo
4997  declare bar
4998  typeset baz
4999}
5000f
5001";
5002        let diagnostics = lint(source, &LinterSettings::for_rule(Rule::UnusedAssignment));
5003
5004        assert_eq!(diagnostics.len(), 3);
5005        assert_eq!(diagnostics[0].span.slice(source), "foo");
5006        assert_eq!(diagnostics[1].span.slice(source), "bar");
5007        assert_eq!(diagnostics[2].span.slice(source), "baz");
5008    }
5009
5010    #[test]
5011    fn initialized_local_declaration_is_flagged_when_unused() {
5012        let diagnostics = lint(
5013            "\
5014#!/bin/bash
5015f() {
5016  local foo=1
5017}
5018f
5019",
5020            &LinterSettings::for_rule(Rule::UnusedAssignment),
5021        );
5022
5023        assert_eq!(diagnostics.len(), 1);
5024        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
5025        assert!(diagnostics[0].message.contains("foo"));
5026    }
5027
5028    #[test]
5029    fn name_only_export_consumes_existing_assignment() {
5030        let diagnostics = lint_for_rule("#!/bin/sh\nfoo=1\nexport foo\n", Rule::UnusedAssignment);
5031        assert!(diagnostics.is_empty());
5032    }
5033
5034    #[test]
5035    fn name_only_readonly_consumes_existing_assignment() {
5036        let diagnostics = lint(
5037            "#!/bin/sh\nfoo=1\nreadonly foo\n",
5038            &LinterSettings::for_rule(Rule::UnusedAssignment),
5039        );
5040        assert!(diagnostics.is_empty());
5041    }
5042
5043    #[test]
5044    fn corpus_false_negative_moduleselfname_is_now_flagged() {
5045        let diagnostics = lint(
5046            "#!/bin/bash\nmoduleselfname=\"$(basename \"$(readlink -f \"${BASH_SOURCE[0]}\")\")\"\n",
5047            &LinterSettings::for_rule(Rule::UnusedAssignment),
5048        );
5049
5050        assert_eq!(diagnostics.len(), 1);
5051        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
5052        assert!(diagnostics[0].message.contains("moduleselfname"));
5053    }
5054
5055    #[test]
5056    fn global_assignment_used_in_a_function_body_is_not_flagged() {
5057        let diagnostics = lint(
5058            "\
5059#!/bin/bash
5060red='\\e[31m'
5061print_red() { printf '%s\\n' \"$red\"; }
5062print_red
5063",
5064            &LinterSettings::for_rule(Rule::UnusedAssignment),
5065        );
5066
5067        assert!(diagnostics.is_empty());
5068    }
5069
5070    #[test]
5071    fn top_level_assignment_read_by_later_function_call_is_not_flagged() {
5072        let diagnostics = lint(
5073            "\
5074#!/bin/sh
5075show() { echo \"$flag\"; }
5076flag=1
5077show
5078",
5079            &LinterSettings::for_rule(Rule::UnusedAssignment),
5080        );
5081
5082        assert!(diagnostics.is_empty());
5083    }
5084
5085    #[test]
5086    fn callee_subshell_reads_keep_caller_assignments_live() {
5087        let diagnostics = lint(
5088            "\
5089#!/bin/bash
5090install_package() {
5091  (
5092    printf '%s\\n' \"$archive_format\" \"${configure[@]}\"
5093  )
5094}
5095install_readline() {
5096  archive_format='tar.gz'
5097  configure=( ./configure --disable-dependency-tracking )
5098  install_package
5099}
5100install_readline
5101",
5102            &LinterSettings::for_rule(Rule::UnusedAssignment),
5103        );
5104
5105        assert!(diagnostics.is_empty());
5106    }
5107
5108    #[test]
5109    fn completion_reply_assignments_are_not_flagged() {
5110        let diagnostics = lint(
5111            "\
5112#!/bin/bash
5113_pyenv() {
5114  COMPREPLY=()
5115  local word=\"${COMP_WORDS[COMP_CWORD]}\"
5116  COMPREPLY=( $(compgen -W \"$(printf 'a b')\" -- \"$word\") )
5117}
5118complete -F _pyenv pyenv
5119",
5120            &LinterSettings::for_rule(Rule::UnusedAssignment),
5121        );
5122
5123        assert!(diagnostics.is_empty());
5124    }
5125
5126    #[test]
5127    fn sourced_helper_reads_keep_top_level_assignment_live() {
5128        let temp = tempdir().unwrap();
5129        let main = temp.path().join("main.sh");
5130        let helper = temp.path().join("helper.sh");
5131        fs::write(
5132            &main,
5133            "\
5134#!/bin/sh
5135flag=1
5136. ./helper.sh
5137",
5138        )
5139        .unwrap();
5140        fs::write(&helper, "echo \"$flag\"\n").unwrap();
5141
5142        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5143
5144        assert!(diagnostics.is_empty());
5145    }
5146
5147    #[test]
5148    fn disabled_source_closure_reports_assignment_only_read_by_sourced_helper() {
5149        let temp = tempdir().unwrap();
5150        let main = temp.path().join("main.sh");
5151        let helper = temp.path().join("lib.sh");
5152        let source = "\
5153#!/bin/sh
5154foo=1
5155. ./lib.sh
5156";
5157        fs::write(&main, source).unwrap();
5158        fs::write(&helper, "printf '%s\\n' \"$foo\"\n").unwrap();
5159
5160        let diagnostics = lint_path(
5161            &main,
5162            &LinterSettings::for_rule(Rule::UnusedAssignment).with_resolve_source_closure(false),
5163        );
5164
5165        assert_eq!(diagnostics.len(), 1);
5166        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
5167        assert_eq!(diagnostics[0].span.slice(source), "foo");
5168    }
5169
5170    #[test]
5171    fn disabled_source_closure_reports_read_only_assigned_by_sourced_helper() {
5172        let temp = tempdir().unwrap();
5173        let main = temp.path().join("main.sh");
5174        let helper = temp.path().join("lib.sh");
5175        let source = "\
5176#!/bin/sh
5177. ./lib.sh
5178printf '%s\\n' \"$foo\"
5179";
5180        fs::write(&main, source).unwrap();
5181        fs::write(&helper, "foo=1\n").unwrap();
5182
5183        let diagnostics = lint_path(
5184            &main,
5185            &LinterSettings::for_rule(Rule::UndefinedVariable).with_resolve_source_closure(false),
5186        );
5187
5188        assert_eq!(diagnostics.len(), 1);
5189        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
5190        assert_eq!(diagnostics[0].span.slice(source), "$foo");
5191    }
5192
5193    #[test]
5194    fn sourced_helper_function_reads_do_not_keep_top_level_assignment_live_until_called() {
5195        let temp = tempdir().unwrap();
5196        let main = temp.path().join("main.sh");
5197        let helper = temp.path().join("helper.sh");
5198        let source = "\
5199#!/bin/sh
5200flag=1
5201. ./helper.sh
5202";
5203        fs::write(&main, source).unwrap();
5204        fs::write(
5205            &helper,
5206            "\
5207use_flag() {
5208  printf '%s\\n' \"$flag\"
5209}
5210",
5211        )
5212        .unwrap();
5213
5214        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5215
5216        assert_eq!(diagnostics.len(), 1);
5217        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
5218        assert_eq!(diagnostics[0].span.slice(source), "flag");
5219    }
5220
5221    #[test]
5222    fn sourced_helper_function_reads_keep_top_level_assignment_live_when_called() {
5223        let temp = tempdir().unwrap();
5224        let main = temp.path().join("main.sh");
5225        let helper = temp.path().join("helper.sh");
5226        fs::write(
5227            &main,
5228            "\
5229#!/bin/sh
5230flag=1
5231. ./helper.sh
5232use_flag
5233",
5234        )
5235        .unwrap();
5236        fs::write(
5237            &helper,
5238            "\
5239use_flag() {
5240  printf '%s\\n' \"$flag\"
5241}
5242",
5243        )
5244        .unwrap();
5245
5246        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5247
5248        assert!(diagnostics.is_empty());
5249    }
5250
5251    #[test]
5252    fn generic_dynamic_source_function_writes_do_not_initialize_c006_reads() {
5253        let temp = tempdir().unwrap();
5254        let main = temp.path().join("tests/main.sh");
5255        let helper = temp.path().join("scripts/helper.sh");
5256        fs::create_dir_all(main.parent().unwrap()).unwrap();
5257        fs::create_dir_all(helper.parent().unwrap()).unwrap();
5258        let source = "\
5259#!/bin/sh
5260helper_root=/tmp
5261. \"$helper_root/scripts/helper.sh\"
5262set_flag
5263printf '%s\\n' \"$flag\"
5264";
5265        fs::write(&main, source).unwrap();
5266        fs::write(
5267            &helper,
5268            "\
5269set_flag() {
5270  flag=1
5271}
5272",
5273        )
5274        .unwrap();
5275
5276        let main_path = main.clone();
5277        let helper_path = helper.clone();
5278        let resolver = move |source_path: &Path, candidate: &str| {
5279            if source_path == main_path.as_path() && candidate == "scripts/helper.sh" {
5280                vec![helper_path.clone()]
5281            } else {
5282                Vec::new()
5283            }
5284        };
5285
5286        let diagnostics =
5287            lint_path_for_rule_with_resolver(&main, Rule::UndefinedVariable, Some(&resolver));
5288
5289        assert_eq!(diagnostics.len(), 1);
5290        assert_eq!(diagnostics[0].rule, Rule::UndefinedVariable);
5291        assert_eq!(diagnostics[0].span.slice(source), "$flag");
5292    }
5293
5294    #[test]
5295    fn source_builtin_reads_keep_top_level_assignment_live() {
5296        let temp = tempdir().unwrap();
5297        let main = temp.path().join("main.bash");
5298        let helper = temp.path().join("helper.bash");
5299        fs::write(
5300            &main,
5301            "\
5302#!/bin/bash
5303flag=1
5304source ./helper.bash
5305",
5306        )
5307        .unwrap();
5308        fs::write(&helper, "echo \"$flag\"\n").unwrap();
5309
5310        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5311
5312        assert!(diagnostics.is_empty());
5313    }
5314
5315    #[test]
5316    fn bash_source_scalar_suffix_source_reads_keep_top_level_assignment_live() {
5317        let temp = tempdir().unwrap();
5318        let main = temp.path().join("main.bash");
5319        let loader = temp.path().join("loader.bash");
5320        let helper = temp.path().join("loader.bash__dep.bash");
5321        fs::write(
5322            &main,
5323            "\
5324#!/bin/bash
5325flag=1
5326source ./loader.bash
5327",
5328        )
5329        .unwrap();
5330        fs::write(
5331            &loader,
5332            "\
5333#!/bin/bash
5334source \"${BASH_SOURCE}__dep.bash\"
5335",
5336        )
5337        .unwrap();
5338        fs::write(&helper, "echo \"$flag\"\n").unwrap();
5339
5340        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5341
5342        assert!(diagnostics.is_empty());
5343    }
5344
5345    #[test]
5346    fn bash_source_index_suffix_source_reads_keep_top_level_assignment_live() {
5347        let temp = tempdir().unwrap();
5348        let main = temp.path().join("main.bash");
5349        let loader = temp.path().join("loader.bash");
5350        let helper = temp.path().join("loader.bash__dep.bash");
5351        fs::write(
5352            &main,
5353            "\
5354#!/bin/bash
5355flag=1
5356source ./loader.bash
5357",
5358        )
5359        .unwrap();
5360        fs::write(
5361            &loader,
5362            "\
5363#!/bin/bash
5364source \"${BASH_SOURCE[0]}__dep.bash\"
5365",
5366        )
5367        .unwrap();
5368        fs::write(&helper, "echo \"$flag\"\n").unwrap();
5369
5370        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5371
5372        assert!(diagnostics.is_empty());
5373    }
5374
5375    #[test]
5376    fn bash_source_double_zero_suffix_source_reads_keep_top_level_assignment_live() {
5377        let temp = tempdir().unwrap();
5378        let main = temp.path().join("main.bash");
5379        let loader = temp.path().join("loader.bash");
5380        let helper = temp.path().join("loader.bash__dep.bash");
5381        fs::write(
5382            &main,
5383            "\
5384#!/bin/bash
5385flag=1
5386source ./loader.bash
5387",
5388        )
5389        .unwrap();
5390        fs::write(
5391            &loader,
5392            "\
5393#!/bin/bash
5394source \"${BASH_SOURCE[00]}__dep.bash\"
5395",
5396        )
5397        .unwrap();
5398        fs::write(&helper, "echo \"$flag\"\n").unwrap();
5399
5400        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5401
5402        assert!(diagnostics.is_empty());
5403    }
5404
5405    #[test]
5406    fn bash_source_spaced_zero_suffix_source_reads_keep_top_level_assignment_live() {
5407        let temp = tempdir().unwrap();
5408        let main = temp.path().join("main.bash");
5409        let loader = temp.path().join("loader.bash");
5410        let helper = temp.path().join("loader.bash__dep.bash");
5411        fs::write(
5412            &main,
5413            "\
5414#!/bin/bash
5415flag=1
5416source ./loader.bash
5417",
5418        )
5419        .unwrap();
5420        fs::write(
5421            &loader,
5422            "\
5423#!/bin/bash
5424source \"${BASH_SOURCE[ 0 ]}__dep.bash\"
5425",
5426        )
5427        .unwrap();
5428        fs::write(&helper, "echo \"$flag\"\n").unwrap();
5429
5430        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5431
5432        assert!(diagnostics.is_empty());
5433    }
5434
5435    #[test]
5436    fn bash_source_nonzero_suffix_source_does_not_keep_top_level_assignment_live() {
5437        let temp = tempdir().unwrap();
5438        let main = temp.path().join("main.bash");
5439        let loader = temp.path().join("loader.bash");
5440        let helper = temp.path().join("loader.bash__dep.bash");
5441        fs::write(
5442            &main,
5443            "\
5444#!/bin/bash
5445flag=1
5446source ./loader.bash
5447",
5448        )
5449        .unwrap();
5450        fs::write(
5451            &loader,
5452            "\
5453#!/bin/bash
5454source \"${BASH_SOURCE[1]}__dep.bash\"
5455",
5456        )
5457        .unwrap();
5458        fs::write(&helper, "echo \"$flag\"\n").unwrap();
5459
5460        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5461
5462        assert_eq!(diagnostics.len(), 1);
5463        assert_eq!(diagnostics[0].span.start.line, 2);
5464        assert_eq!(diagnostics[0].span.start.column, 1);
5465    }
5466
5467    #[test]
5468    fn bash_source_scalar_dirname_source_reads_keep_top_level_assignment_live() {
5469        let temp = tempdir().unwrap();
5470        let main = temp.path().join("main.bash");
5471        let loader = temp.path().join("loader.bash");
5472        let helper = temp.path().join("helper.bash");
5473        fs::write(
5474            &main,
5475            "\
5476#!/bin/bash
5477flag=1
5478source ./loader.bash
5479",
5480        )
5481        .unwrap();
5482        fs::write(
5483            &loader,
5484            "\
5485#!/bin/bash
5486source \"$(dirname \"$BASH_SOURCE\")/helper.bash\"
5487",
5488        )
5489        .unwrap();
5490        fs::write(&helper, "echo \"$flag\"\n").unwrap();
5491
5492        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5493
5494        assert!(diagnostics.is_empty());
5495    }
5496
5497    #[test]
5498    fn bash_source_index_dirname_source_reads_keep_top_level_assignment_live() {
5499        let temp = tempdir().unwrap();
5500        let main = temp.path().join("main.bash");
5501        let loader = temp.path().join("loader.bash");
5502        let helper = temp.path().join("helper.bash");
5503        fs::write(
5504            &main,
5505            "\
5506#!/bin/bash
5507flag=1
5508source ./loader.bash
5509",
5510        )
5511        .unwrap();
5512        fs::write(
5513            &loader,
5514            "\
5515#!/bin/bash
5516source \"$(dirname \"${BASH_SOURCE[0]}\")/helper.bash\"
5517",
5518        )
5519        .unwrap();
5520        fs::write(&helper, "echo \"$flag\"\n").unwrap();
5521
5522        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5523
5524        assert!(diagnostics.is_empty());
5525    }
5526
5527    #[test]
5528    fn executed_helper_reads_keep_loop_variable_live() {
5529        let temp = tempdir().unwrap();
5530        let main = temp.path().join("main.sh");
5531        let helper = temp.path().join("helper.sh");
5532        fs::write(
5533            &main,
5534            "\
5535#!/bin/sh
5536for queryip in 127.0.0.1; do
5537  helper.sh
5538done
5539",
5540        )
5541        .unwrap();
5542        fs::write(&helper, "printf '%s\\n' \"$queryip\"\n").unwrap();
5543
5544        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5545
5546        assert!(diagnostics.is_empty());
5547    }
5548
5549    #[test]
5550    fn executed_helper_without_read_still_flags_unused_assignment() {
5551        let temp = tempdir().unwrap();
5552        let main = temp.path().join("main.sh");
5553        let helper = temp.path().join("helper.sh");
5554        let source = "\
5555#!/bin/sh
5556unused=1
5557helper.sh
5558";
5559        fs::write(&main, source).unwrap();
5560        fs::write(&helper, "printf '%s\\n' ok\n").unwrap();
5561
5562        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5563
5564        assert_eq!(diagnostics.len(), 1);
5565        assert_eq!(diagnostics[0].rule, Rule::UnusedAssignment);
5566        assert_eq!(diagnostics[0].span.slice(source), "unused");
5567    }
5568
5569    #[test]
5570    fn loader_function_source_reads_keep_top_level_assignment_live() {
5571        let temp = tempdir().unwrap();
5572        let main = temp.path().join("main.sh");
5573        let helper = temp.path().join("helper.sh");
5574        fs::write(
5575            &main,
5576            "\
5577#!/bin/sh
5578load() { . \"$ROOT/$1\"; }
5579flag=1
5580load helper.sh
5581",
5582        )
5583        .unwrap();
5584        fs::write(&helper, "echo \"$flag\"\n").unwrap();
5585
5586        let diagnostics = lint_path_for_rule(&main, Rule::UnusedAssignment);
5587
5588        assert!(diagnostics.is_empty());
5589    }
5590
5591    #[test]
5592    fn unreachable_after_exit_reports_each_unreachable_command() {
5593        let source = "\
5594#!/bin/bash
5595if [ -f /etc/hosts ]; then
5596  echo found
5597  exit 0
5598else
5599  echo missing
5600  exit 1
5601fi
5602echo unreachable
5603printf '%s\\n' never
5604f() {
5605  return 0
5606  echo also_unreachable
5607}
5608f
5609";
5610        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5611
5612        assert_eq!(diagnostics.len(), 4);
5613        assert!(
5614            diagnostics
5615                .iter()
5616                .all(|diagnostic| diagnostic.rule == Rule::UnreachableAfterExit)
5617        );
5618        assert_eq!(diagnostics[0].span.slice(source), "echo unreachable");
5619        assert_eq!(diagnostics[1].span.slice(source), "printf '%s\\n' never");
5620        assert_eq!(
5621            diagnostics[2].span.slice(source),
5622            "f() {\n  return 0\n  echo also_unreachable\n}"
5623        );
5624        assert_eq!(diagnostics[3].span.slice(source), "f");
5625    }
5626
5627    #[test]
5628    fn unreachable_after_exit_prefers_outermost_compound_command_spans() {
5629        let source = "\
5630#!/bin/bash
5631return
5632if true; then
5633  echo one
5634fi
5635printf '%s\\n' two
5636";
5637        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5638
5639        assert_eq!(diagnostics.len(), 2);
5640        assert_eq!(
5641            diagnostics[0].span.slice(source),
5642            "if true; then\n  echo one\nfi"
5643        );
5644        assert_eq!(diagnostics[0].span.end.line, 5);
5645        assert_eq!(diagnostics[0].span.end.column, 3);
5646        assert_eq!(diagnostics[1].span.slice(source), "printf '%s\\n' two");
5647        assert_eq!(diagnostics[1].span.end.line, 6);
5648        assert_eq!(diagnostics[1].span.end.column, 18);
5649    }
5650
5651    #[test]
5652    fn unreachable_after_exit_treats_zsh_always_cleanup_as_reachable() {
5653        let source = "\
5654#!/bin/zsh
5655{
5656  return
5657} always {
5658  printf '%s\\n' cleanup
5659}
5660printf '%s\\n' after
5661";
5662        let diagnostics = crate::test::test_snippet(
5663            source,
5664            &LinterSettings::for_rule(Rule::UnreachableAfterExit).with_shell(ShellDialect::Zsh),
5665        );
5666
5667        assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}");
5668        assert_eq!(diagnostics[0].span.slice(source), "printf '%s\\n' after");
5669    }
5670
5671    #[test]
5672    fn unreachable_after_exit_reports_after_script_terminating_function_calls() {
5673        let source = "\
5674#!/bin/bash
5675exit_script() {
5676  exit 0
5677}
5678main() {
5679  exit_script
5680  printf '%s\\n' never
5681}
5682";
5683        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5684
5685        assert_eq!(diagnostics.len(), 1);
5686        assert_eq!(diagnostics[0].span.slice(source), "printf '%s\\n' never");
5687    }
5688
5689    #[test]
5690    fn unreachable_after_exit_ignores_helper_exit_calls_in_sourceable_files() {
5691        let source = "\
5692#!/bin/sh
5693[ -n \"$loaded\" ] && return
5694loaded=1
5695exit_script() {
5696  exit 0
5697}
5698main() {
5699  exit_script
5700  printf '%s\\n' still_reachable
5701}
5702";
5703        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5704
5705        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
5706    }
5707
5708    #[test]
5709    fn unreachable_after_exit_ignores_statements_inside_unreached_functions() {
5710        let source = "\
5711#!/bin/bash
5712helper() {
5713  return 0
5714  printf '%s\\n' unreachable_inside_unreached_function
5715}
5716exit 0
5717";
5718        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5719
5720        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
5721    }
5722
5723    #[test]
5724    fn unreachable_after_exit_ignores_dynamic_dispatch_only_functions_before_exit() {
5725        let source = "\
5726#!/bin/bash
5727dispatch() {
5728  \"$command\"
5729}
5730helper() {
5731  return 0
5732  printf '%s\\n' unreachable_inside_dynamic_target
5733}
5734dispatch
5735exit 0
5736";
5737        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5738
5739        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
5740    }
5741
5742    #[test]
5743    fn unreachable_after_exit_reports_inside_transitively_called_functions_before_exit() {
5744        let source = "\
5745#!/bin/bash
5746helper() {
5747  return 0
5748  printf '%s\\n' still_reported
5749}
5750main() {
5751  helper
5752}
5753main
5754exit 0
5755";
5756        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5757
5758        assert_eq!(diagnostics.len(), 1);
5759        assert_eq!(
5760            diagnostics[0].span.slice(source),
5761            "printf '%s\\n' still_reported"
5762        );
5763    }
5764
5765    #[test]
5766    fn unreachable_after_exit_reports_inside_later_defined_transitive_functions() {
5767        let source = "\
5768#!/bin/bash
5769main() {
5770  helper
5771}
5772helper() {
5773  return 0
5774  printf '%s\\n' still_reported
5775}
5776main
5777exit 0
5778";
5779        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5780
5781        assert_eq!(diagnostics.len(), 1);
5782        assert_eq!(
5783            diagnostics[0].span.slice(source),
5784            "printf '%s\\n' still_reported"
5785        );
5786    }
5787
5788    #[test]
5789    fn unreachable_after_exit_reports_inside_called_nested_functions_before_exit() {
5790        let source = "\
5791#!/bin/bash
5792outer() {
5793  helper() {
5794    return 0
5795    printf '%s\\n' still_reported_nested
5796  }
5797  helper
5798}
5799outer
5800exit 0
5801";
5802        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5803
5804        assert_eq!(diagnostics.len(), 1);
5805        assert_eq!(
5806            diagnostics[0].span.slice(source),
5807            "printf '%s\\n' still_reported_nested"
5808        );
5809    }
5810
5811    #[test]
5812    fn unreachable_after_exit_reports_before_sourceable_footer_return() {
5813        let source = "\
5814#!/bin/bash
5815finish() {
5816  exit \"$1\"
5817}
5818terminal() {
5819  finish 34 && return 34
5820}
5821return 0
5822";
5823        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5824
5825        assert_eq!(diagnostics.len(), 1);
5826        assert_eq!(diagnostics[0].span.slice(source), "return 34");
5827    }
5828
5829    #[test]
5830    fn unreachable_after_exit_reports_uncalled_function_when_exit_is_conditional() {
5831        let source = "\
5832#!/bin/bash
5833helper() {
5834  return 0
5835  printf '%s\\n' still_reported
5836}
5837if maybe; then
5838  exit 0
5839fi
5840";
5841        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5842
5843        assert_eq!(diagnostics.len(), 1);
5844        assert_eq!(
5845            diagnostics[0].span.slice(source),
5846            "printf '%s\\n' still_reported"
5847        );
5848    }
5849
5850    #[test]
5851    fn unreachable_after_exit_reports_after_redirected_exit_helpers() {
5852        let source = "\
5853#!/bin/bash
5854exit_script() {
5855  exit 0
5856}
5857main() {
5858  exit_script >/dev/null
5859  printf '%s\\n' never
5860}
5861";
5862        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5863
5864        assert_eq!(diagnostics.len(), 1);
5865        assert_eq!(diagnostics[0].span.slice(source), "printf '%s\\n' never");
5866    }
5867
5868    #[test]
5869    fn unreachable_after_exit_reports_condition_body_after_terminating_condition() {
5870        let source = "\
5871#!/bin/bash
5872if exit 0; then
5873  printf '%s\\n' never
5874fi
5875";
5876        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5877
5878        assert_eq!(diagnostics.len(), 1);
5879        assert_eq!(diagnostics[0].span.slice(source), "printf '%s\\n' never");
5880    }
5881
5882    #[test]
5883    fn unreachable_after_exit_includes_redirects_but_not_statement_terminators() {
5884        let source = "\
5885#!/bin/bash
5886exit 0
5887while read -r item; do
5888  printf '%s\\n' \"$item\"
5889done < input.txt;
5890";
5891        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5892
5893        assert_eq!(diagnostics.len(), 1);
5894        assert_eq!(
5895            diagnostics[0].span.slice(source),
5896            "while read -r item; do\n  printf '%s\\n' \"$item\"\ndone < input.txt"
5897        );
5898    }
5899
5900    #[test]
5901    fn unreachable_after_exit_ignores_loop_control_only_dead_code() {
5902        let source = "\
5903#!/bin/bash
5904while true; do
5905  break; printf '%s\\n' after_break
5906done
5907";
5908        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5909
5910        assert!(diagnostics.is_empty());
5911    }
5912
5913    #[test]
5914    fn unreachable_after_exit_ignores_loop_control_if_branches_and_following_code() {
5915        let source = "\
5916#!/bin/bash
5917while true; do
5918  if break; then
5919    printf '%s\\n' after_true
5920  else
5921    printf '%s\\n' after_false
5922  fi
5923  printf '%s\\n' after_if
5924done
5925";
5926        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5927
5928        assert!(diagnostics.is_empty());
5929    }
5930
5931    #[test]
5932    fn unreachable_after_exit_reports_after_brace_group_defined_exit_helpers() {
5933        let source = "\
5934#!/bin/bash
5935{
5936  exit_script() {
5937    exit 0
5938  }
5939}
5940main() {
5941  exit_script
5942  printf '%s\\n' never
5943}
5944";
5945        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5946
5947        assert_eq!(diagnostics.len(), 1);
5948        assert_eq!(diagnostics[0].span.slice(source), "printf '%s\\n' never");
5949    }
5950
5951    #[test]
5952    fn unreachable_after_exit_reports_after_later_parent_scope_exit_helpers() {
5953        let source = "\
5954#!/bin/bash
5955main() {
5956  exit_script
5957  printf '%s\\n' never
5958}
5959exit_script() {
5960  exit 0
5961}
5962main
5963";
5964        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5965
5966        assert_eq!(diagnostics.len(), 1);
5967        assert_eq!(diagnostics[0].span.slice(source), "printf '%s\\n' never");
5968    }
5969
5970    #[test]
5971    fn unreachable_after_exit_ignores_later_function_definitions_for_earlier_calls() {
5972        let source = "\
5973#!/bin/bash
5974main() {
5975  exit_script
5976  printf '%s\\n' still_reachable
5977}
5978main
5979exit_script() {
5980  exit 0
5981}
5982";
5983        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
5984
5985        assert!(diagnostics.is_empty());
5986    }
5987
5988    #[test]
5989    fn unreachable_after_exit_ignores_transitive_calls_before_parent_definitions() {
5990        let source = "\
5991#!/bin/bash
5992main() {
5993  helper
5994}
5995helper() {
5996  inner
5997}
5998inner() {
5999  exit_script
6000  printf '%s\\n' maybe
6001}
6002if should_run; then
6003  main
6004fi
6005exit_script() {
6006  exit 0
6007}
6008main
6009";
6010        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
6011
6012        assert!(diagnostics.is_empty());
6013    }
6014
6015    #[test]
6016    fn unreachable_after_exit_ignores_stale_terminating_helper_redefinitions() {
6017        let source = "\
6018#!/bin/bash
6019exit_script() {
6020  exit 0
6021}
6022main() {
6023  exit_script
6024  printf '%s\\n' maybe
6025}
6026if should_run; then
6027  main
6028fi
6029exit_script() {
6030  :
6031}
6032main
6033";
6034        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
6035
6036        assert!(diagnostics.is_empty());
6037    }
6038
6039    #[test]
6040    fn unreachable_after_exit_ignores_conditionally_defined_exit_helpers() {
6041        let source = "\
6042#!/bin/bash
6043if false; then
6044  exit_script() {
6045    exit 0
6046  }
6047fi
6048exit_script
6049printf '%s\\n' still_reachable
6050";
6051        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
6052
6053        assert!(diagnostics.is_empty());
6054    }
6055
6056    #[test]
6057    fn unreachable_after_exit_ignores_fallback_after_conditional_exit() {
6058        let source = "\
6059#!/bin/bash
6060run && exit 0 || sleep 15
6061";
6062        let diagnostics = lint(source, &LinterSettings::default());
6063
6064        assert_eq!(
6065            diagnostics
6066                .iter()
6067                .filter(|diagnostic| diagnostic.rule == Rule::ChainedTestBranches)
6068                .count(),
6069            1
6070        );
6071        assert!(
6072            diagnostics
6073                .iter()
6074                .all(|diagnostic| diagnostic.rule != Rule::UnreachableAfterExit),
6075            "diagnostics: {diagnostics:?}"
6076        );
6077    }
6078
6079    #[test]
6080    fn unreachable_after_exit_skips_dead_short_circuit_lists() {
6081        let source = "\
6082#!/bin/bash
6083exit 0
6084echo one && echo two
6085echo after
6086";
6087        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
6088
6089        assert_eq!(diagnostics.len(), 1);
6090        assert_eq!(diagnostics[0].span.slice(source), "echo after");
6091    }
6092
6093    #[test]
6094    fn unreachable_after_exit_skips_dead_short_circuit_exit_guards() {
6095        let source = "\
6096#!/bin/bash
6097exit 0
6098cleanup || exit 1
6099echo after
6100printf '%s\\n' later
6101";
6102        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
6103
6104        assert_eq!(diagnostics.len(), 2);
6105        assert_eq!(diagnostics[0].span.slice(source), "echo after");
6106        assert_eq!(diagnostics[1].span.slice(source), "printf '%s\\n' later");
6107    }
6108
6109    #[test]
6110    fn unreachable_after_exit_skips_dead_short_circuit_segments() {
6111        let source = "\
6112#!/bin/bash
6113usage() { exit 0; }
6114error() {
6115  [ $# -eq 0 ] && usage && exit 0
6116  echo after
6117}
6118";
6119        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
6120
6121        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
6122    }
6123
6124    #[test]
6125    fn unreachable_after_exit_reports_nested_dead_code_in_skipped_short_circuit_segments() {
6126        let source = "\
6127#!/bin/bash
6128check() {
6129  [ \"$1\" = stop ] && { return 0; echo inner; } && echo tail
6130}
6131";
6132        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
6133
6134        assert_eq!(diagnostics.len(), 1);
6135        assert_eq!(diagnostics[0].span.slice(source), "echo inner");
6136    }
6137
6138    #[test]
6139    fn unreachable_after_exit_reports_shadowed_condition_names_in_short_circuit_lists() {
6140        let source = "\
6141#!/bin/bash
6142true() {
6143  exit 0
6144}
6145check() {
6146  true && echo a && echo b
6147}
6148";
6149        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
6150
6151        assert_eq!(diagnostics.len(), 2);
6152        assert_eq!(diagnostics[0].span.slice(source), "echo a");
6153        assert_eq!(diagnostics[1].span.slice(source), "echo b");
6154    }
6155
6156    #[test]
6157    fn unreachable_after_exit_reports_shadowed_condition_wrapper_names() {
6158        for wrapper in ["command", "builtin", "sudo", "doas", "run0"] {
6159            let source = format!(
6160                "\
6161#!/bin/bash
6162{wrapper}() {{
6163  exit 0
6164}}
6165check() {{
6166  {wrapper} true && echo a && echo b
6167}}
6168"
6169            );
6170            let diagnostics = lint_for_rule(&source, Rule::UnreachableAfterExit);
6171
6172            assert_eq!(diagnostics.len(), 2, "{wrapper}: {diagnostics:?}");
6173            assert_eq!(diagnostics[0].span.slice(&source), "echo a", "{wrapper}");
6174            assert_eq!(diagnostics[1].span.slice(&source), "echo b", "{wrapper}");
6175        }
6176    }
6177
6178    #[test]
6179    fn unreachable_after_exit_ignores_conditionally_defined_condition_names() {
6180        let source = "\
6181#!/bin/bash
6182die() {
6183  exit 1
6184}
6185check() {
6186  if maybe; then
6187    true() { exit 0; }
6188  fi
6189  true && die && exit 1
6190}
6191";
6192        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
6193
6194        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
6195    }
6196
6197    #[test]
6198    fn unreachable_after_exit_keeps_dead_two_segment_short_circuit_tail() {
6199        let source = "\
6200#!/bin/bash
6201finish() { exit \"$1\"; }
6202terminal() {
6203  finish 34 && return 34
6204}
6205";
6206        let diagnostics = lint_for_rule(source, Rule::UnreachableAfterExit);
6207
6208        assert_eq!(diagnostics.len(), 1);
6209        assert_eq!(diagnostics[0].span.slice(source), "return 34");
6210    }
6211
6212    #[test]
6213    fn unused_assignment_respects_disabled_rule() {
6214        let diagnostics = lint(
6215            "#!/bin/sh\nfoo=1\n",
6216            &LinterSettings {
6217                rules: RuleSet::EMPTY,
6218                ..LinterSettings::default()
6219            },
6220        );
6221        assert!(diagnostics.is_empty());
6222    }
6223
6224    #[test]
6225    fn unused_assignment_suppressed_by_shellcheck_directive() {
6226        let source = "\
6227#!/bin/sh
6228# shellcheck disable=SC2034
6229foo=1
6230";
6231        let diagnostics = lint(source, &LinterSettings::default());
6232        assert!(diagnostics.is_empty());
6233    }
6234
6235    #[test]
6236    fn parsed_result_linting_respects_shellcheck_directive() {
6237        let source = "\
6238#!/bin/sh
6239# shellcheck disable=SC2034
6240foo=1
6241";
6242        let output = Parser::new(source).parse().unwrap();
6243        let indexer = Indexer::new(source, &output);
6244        let directives = parse_directives(
6245            source,
6246            indexer.comment_index(),
6247            &ShellCheckCodeMap::default(),
6248        );
6249        let settings = LinterSettings::default();
6250        let diagnostics = AnalysisRequest::from_parse_result(&output, source, &settings)
6251            .with_directives(&directives)
6252            .lint();
6253        assert!(diagnostics.is_empty());
6254    }
6255
6256    #[test]
6257    fn unused_assignment_suppression_stays_on_matching_binding_line() {
6258        let source = "\
6259#!/bin/bash
6260:
6261# shellcheck disable=SC2034
6262foo=1
6263foo=2
6264";
6265        let diagnostics = lint(source, &LinterSettings::default());
6266        assert_eq!(diagnostics.len(), 1);
6267        assert_eq!(diagnostics[0].span.start.line, 5);
6268    }
6269
6270    #[test]
6271    fn redundant_return_status_suppressed_by_legacy_shuck_directive() {
6272        let source = "\
6273#!/bin/sh
6274# shuck: disable=SH-170
6275f() {
6276  false
6277  return $?
6278}
6279";
6280        let diagnostics = lint_for_rule(source, Rule::RedundantReturnStatus);
6281        assert!(diagnostics.is_empty());
6282    }
6283
6284    #[test]
6285    fn local_top_level_suppressed_by_shellcheck_directive() {
6286        let source = "\
6287#!/bin/bash
6288# shellcheck disable=SC2168
6289local foo=bar
6290printf '%s\\n' \"$foo\"
6291";
6292        let diagnostics = lint(source, &LinterSettings::default());
6293        assert!(diagnostics.is_empty());
6294    }
6295
6296    #[test]
6297    fn local_declare_combined_suppressed_by_shellcheck_alias_directive() {
6298        let source = "\
6299#!/bin/bash
6300# shellcheck disable=SC2316
6301f() {
6302  local declare hard_list
6303  echo \"$hard_list\"
6304}
6305";
6306        let diagnostics = lint_for_rule(source, Rule::LocalDeclareCombined);
6307        assert!(diagnostics.is_empty());
6308    }
6309
6310    #[test]
6311    fn backtick_in_command_position_suppressed_by_shellcheck_alias_directive() {
6312        let source = "\
6313#!/bin/sh
6314# shellcheck disable=SC2316
6315`echo hello` | cat
6316";
6317        let diagnostics = lint_for_rule(source, Rule::BacktickInCommandPosition);
6318        assert!(diagnostics.is_empty());
6319    }
6320
6321    #[test]
6322    fn compound_test_operator_suppressed_by_shellcheck_disable_all() {
6323        let source = "\
6324#!/bin/bash
6325# shellcheck disable=all
6326[ \"$a\" = 1 -a \"$b\" = 2 ]
6327";
6328        let diagnostics = lint_for_rule(source, Rule::CompoundTestOperator);
6329        assert!(diagnostics.is_empty());
6330    }
6331
6332    #[test]
6333    fn local_in_sh_suppressed_by_shellcheck_directive() {
6334        let source = "\
6335#!/bin/sh
6336# shellcheck disable=SC3043
6337f() {
6338  local foo=bar
6339  printf '%s\\n' \"$foo\"
6340}
6341f
6342";
6343        let diagnostics = lint_for_rule(source, Rule::LocalVariableInSh);
6344        assert!(diagnostics.is_empty());
6345    }
6346
6347    #[test]
6348    fn function_keyword_suppressed_by_shellcheck_directive() {
6349        let source = "\
6350#!/bin/sh
6351# shellcheck disable=SC2113
6352function f { :; }
6353";
6354        let diagnostics = lint_for_rule(source, Rule::FunctionKeyword);
6355        assert!(diagnostics.is_empty());
6356    }
6357
6358    #[test]
6359    fn backslash_before_command_suppressed_by_shellcheck_directive() {
6360        let source = "\
6361#!/bin/sh
6362# shellcheck disable=SC2268
6363\\command printf '%s\\n' hi
6364";
6365        let diagnostics = lint_for_rule(source, Rule::BackslashBeforeCommand);
6366        assert!(diagnostics.is_empty());
6367    }
6368
6369    #[test]
6370    fn literal_control_escape_suppressed_by_shellcheck_directive() {
6371        let source = "\
6372#!/bin/sh
6373# shellcheck disable=SC1012
6374echo \\n
6375";
6376        let diagnostics = lint_for_rule(source, Rule::LiteralControlEscape);
6377        assert!(diagnostics.is_empty());
6378    }
6379
6380    #[test]
6381    fn let_command_suppressed_by_shellcheck_directive() {
6382        let source = "\
6383#!/bin/sh
6384# shellcheck disable=SC3042
6385let x=1
6386";
6387        let diagnostics = lint_for_rule(source, Rule::LetCommand);
6388        assert!(diagnostics.is_empty());
6389    }
6390
6391    #[test]
6392    fn declare_command_suppressed_by_shellcheck_directive() {
6393        let source = "\
6394#!/bin/sh
6395# shellcheck disable=SC3044
6396declare foo=bar
6397";
6398        let diagnostics = lint_for_rule(source, Rule::DeclareCommand);
6399        assert!(diagnostics.is_empty());
6400    }
6401
6402    #[test]
6403    fn source_builtin_in_sh_suppressed_by_shellcheck_directive() {
6404        let source = "\
6405#!/bin/sh
6406# shellcheck disable=SC3046
6407source ./helpers.sh
6408";
6409        let diagnostics = lint_for_rule(source, Rule::SourceBuiltinInSh);
6410        assert!(diagnostics.is_empty());
6411    }
6412
6413    #[test]
6414    fn function_keyword_with_parens_suppressed_by_shellcheck_directive() {
6415        let source = "\
6416#!/bin/sh
6417# shellcheck disable=SC2112
6418function f() { :; }
6419";
6420        let diagnostics = lint_for_rule(source, Rule::FunctionKeywordInSh);
6421        assert!(diagnostics.is_empty());
6422    }
6423
6424    #[test]
6425    fn array_index_arithmetic_suppressed_by_shellcheck_directive() {
6426        let source = "\
6427#!/bin/bash
6428# shellcheck disable=SC2321
6429arr[$((1+1))]=x
6430";
6431        let diagnostics = lint_for_rule(source, Rule::ArrayIndexArithmetic);
6432        assert!(diagnostics.is_empty());
6433    }
6434
6435    #[test]
6436    fn source_inside_function_suppressed_by_shellcheck_directive() {
6437        let source = "\
6438#!/bin/sh
6439# shellcheck disable=SC3084
6440f() {
6441  source ./helpers.sh
6442}
6443";
6444        let diagnostics = lint_for_rule(source, Rule::SourceInsideFunctionInSh);
6445        assert!(diagnostics.is_empty());
6446    }
6447}