1#![warn(missing_docs)]
2#![cfg_attr(not(test), warn(clippy::unwrap_used))]
3
4mod 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#[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#[allow(missing_docs)]
49pub mod test;
50
51pub use ambient_contracts::{
52 AmbientContractActivation, AmbientContractConfig, AmbientContractEffects, AmbientContractSpec,
53 AmbientFunctionContractSpec, EffectiveAmbientContracts, ResolvedAmbientContracts,
54 ResolvedAmbientRequestContracts,
55};
56pub use checker::Checker;
58pub use diagnostic::{Diagnostic, Severity};
60pub 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};
68pub 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};
84pub 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};
92pub 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;
96pub use named_groups::NamedGroup;
98pub use registry::{Category, Rule, code_to_rule};
100pub use rule_metadata::{RuleMetadata, ShellCheckLevel, rule_metadata, rule_metadata_by_code};
102pub use rule_selector::{RuleSelector, SelectorParseError};
103pub use rule_set::RuleSet;
105#[allow(unused_imports)]
106pub(crate) use rules::common::word::conditional_binary_op_is_string_match;
107pub 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};
114pub use shell::ShellDialect;
116pub use shuck_semantic::{
118 ArithmeticLiteralBehavior, FieldSplittingBehavior, GlobDotBehavior, GlobFailureBehavior,
119 GlobPatternBehavior, PathnameExpansionBehavior, PatternOperatorBehavior,
120 SubscriptIndexBehavior,
121};
122pub 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};
128pub 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
149pub struct AnalysisResult {
151 pub semantic: SemanticModel,
153 pub diagnostics: Vec<Diagnostic>,
155}
156
157pub 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 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 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 pub fn with_source_path(mut self, source_path: &'a Path) -> Self {
228 self.source_path = Some(source_path);
229 self
230 }
231
232 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 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 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 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 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 pub fn with_suppression_index(mut self, suppression_index: &'a SuppressionIndex) -> Self {
276 self.suppressions = SuppressionRequest::Index(suppression_index);
277 self
278 }
279
280 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 pub fn without_suppressions(mut self) -> Self {
293 self.suppressions = SuppressionRequest::None;
294 self
295 }
296
297 pub fn with_directives(mut self, directives: &'a [SuppressionDirective]) -> Self {
299 self.suppressions = SuppressionRequest::Directives(directives);
300 self
301 }
302
303 pub fn with_shellcheck_map(mut self, shellcheck_map: &'a ShellCheckCodeMap) -> Self {
305 self.suppressions = SuppressionRequest::ShellCheckMap(shellcheck_map);
306 self
307 }
308
309 pub fn analyze(self) -> AnalysisResult {
311 analyze(self)
312 }
313
314 pub fn lint(self) -> Vec<Diagnostic> {
316 lint(self)
317 }
318
319 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
361pub 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 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 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 pub fn semantic(&self) -> &SemanticModel {
413 &self.semantic
414 }
415
416 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
513pub(crate) enum CommandTopologyTraversal {
514 Descend,
516 SkipChildren,
518 Break,
520}
521
522#[derive(Clone, Copy)]
530pub(crate) struct CommandTopology<'a, 'artifacts> {
531 artifacts: &'artifacts LinterSemanticArtifacts<'a>,
532}
533
534impl<'a, 'artifacts> CommandTopology<'a, 'artifacts> {
535 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 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 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 pub(crate) fn command_context(&self, id: CommandId) -> Option<&SemanticCommandContext> {
566 self.artifacts.semantic.command_context(id)
567 }
568
569 #[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 #[allow(dead_code)]
577 pub(crate) fn command_children(&self, id: CommandId) -> &[CommandId] {
578 self.artifacts.semantic.command_children(id)
579 }
580
581 #[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 pub(crate) fn syntax_backed_command_children(&self, id: CommandId) -> &[CommandId] {
589 self.artifacts.semantic.syntax_backed_command_children(id)
590 }
591
592 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 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#[allow(dead_code)]
656#[derive(Debug, Clone, Copy)]
657pub(crate) struct BodyCommandVisit<'a> {
658 pub(crate) id: CommandId,
660 pub(crate) visit: facts::CommandVisit<'a>,
662}
663
664#[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 #[allow(dead_code)]
678 pub(crate) fn direct_command_ids(&self) -> &'artifacts [CommandId] {
679 self.direct_ids
680 }
681
682 #[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 #[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 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
853pub 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
870pub 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}