Skip to main content

shuck_linter/
settings.rs

1use std::path::Path;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::{Context, Result};
6use globset::{Glob, GlobMatcher};
7use rustc_hash::FxHashMap;
8use rustc_hash::FxHashSet;
9use shuck_semantic::{UnreachedFunctionAnalysisOptions, UnusedAssignmentAnalysisOptions};
10
11use crate::ambient_contracts::ResolvedAmbientContracts;
12use crate::{Category, Rule, RuleSelector, RuleSet, Severity, ShellDialect};
13
14const DEFAULT_DISABLED_NON_STYLE_RULES: &[Rule] = &[
15    Rule::ImplicitGlobalInFunction,
16    Rule::MutableGlobal,
17    Rule::UnanchoredSourcePath,
18    Rule::FunctionCalledBeforeDefined,
19];
20const DEFAULT_C160_ALLOWED_ANCHORS: &[&str] = &[
21    "${BASH_SOURCE[0]%/*}",
22    "$(dirname \"$0\")",
23    "$(dirname \"${BASH_SOURCE[0]}\")",
24];
25
26/// Per-rule behavior overrides applied during lint analysis.
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct LinterRuleOptions {
29    /// Behavior overrides for `C001`.
30    pub c001: C001RuleOptions,
31    /// Behavior overrides for `C063`.
32    pub c063: C063RuleOptions,
33    /// Behavior overrides for `S078`.
34    pub s078: S078RuleOptions,
35    /// Behavior overrides for `S079`.
36    pub s079: S079RuleOptions,
37    /// Behavior overrides for `S080`.
38    pub s080: S080RuleOptions,
39    /// Behavior overrides for `S081`.
40    pub s081: S081RuleOptions,
41    /// Behavior overrides for `S082`.
42    pub s082: S082RuleOptions,
43    /// Behavior overrides for `S083`.
44    pub s083: S083RuleOptions,
45    /// Behavior overrides for `S084`.
46    pub s084: S084RuleOptions,
47    /// Behavior overrides for `S085`.
48    pub s085: S085RuleOptions,
49    /// Behavior overrides for `C158`.
50    pub c158: C158RuleOptions,
51    /// Behavior overrides for `C159`.
52    pub c159: C159RuleOptions,
53    /// Behavior overrides for `C160`.
54    pub c160: C160RuleOptions,
55    /// Behavior overrides for `C161`.
56    pub c161: C161RuleOptions,
57}
58
59/// Behavior overrides for `C001` unused-assignment analysis.
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct C001RuleOptions {
62    /// Whether scalar indirect-expansion targets like `${!name}` count as a use of the target.
63    /// Disabled by default to match ShellCheck. Array-like targets such as
64    /// `name=arr[@]; ${!name}` stay live in both modes.
65    pub treat_indirect_expansion_targets_as_used: bool,
66}
67
68impl C001RuleOptions {
69    pub(crate) fn semantic_options(&self) -> UnusedAssignmentAnalysisOptions {
70        UnusedAssignmentAnalysisOptions {
71            treat_indirect_expansion_targets_as_used: self.treat_indirect_expansion_targets_as_used,
72            report_unreachable_assignments: true,
73        }
74    }
75}
76
77/// Behavior overrides for `C063` overwritten/unreached function analysis.
78#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct C063RuleOptions {
80    /// Whether nested function definitions should be reported when no reachable direct call
81    /// reaches the enclosing function scope before that scope exits.
82    pub report_unreached_nested_definitions: bool,
83}
84
85impl C063RuleOptions {
86    pub(crate) fn semantic_options(&self) -> UnreachedFunctionAnalysisOptions {
87        UnreachedFunctionAnalysisOptions {
88            report_unreached_nested_definitions: self.report_unreached_nested_definitions,
89        }
90    }
91}
92/// Behavior overrides for `S080` script size policy.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct S080RuleOptions {
95    /// Maximum line count accepted for one script.
96    pub max_lines: usize,
97    /// Which source lines count toward the threshold.
98    pub count: String,
99}
100
101impl Default for S080RuleOptions {
102    fn default() -> Self {
103        Self {
104            max_lines: 100,
105            count: "physical".to_owned(),
106        }
107    }
108}
109
110/// Behavior overrides for `S078` shebang shell policy.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct S078RuleOptions {
113    /// Interpreter names accepted in shebangs for this project.
114    pub allowed_shells: Vec<String>,
115}
116
117impl Default for S078RuleOptions {
118    fn default() -> Self {
119        Self {
120            allowed_shells: vec!["bash".to_owned()],
121        }
122    }
123}
124
125/// Behavior overrides for `S079` shebang invocation form policy.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct S079RuleOptions {
128    /// Invocation forms accepted for shebangs in this project.
129    pub allowed_forms: Vec<String>,
130    /// Exact shebang invocation strings that are accepted regardless of form.
131    pub allowed_paths: Vec<String>,
132}
133
134impl Default for S079RuleOptions {
135    fn default() -> Self {
136        Self {
137            allowed_forms: vec!["env-lookup".to_owned()],
138            allowed_paths: vec!["/bin/bash".to_owned(), "/usr/bin/env bash".to_owned()],
139        }
140    }
141}
142
143/// Behavior overrides for `S081` file description comments.
144#[derive(Debug, Clone, Default, PartialEq, Eq)]
145pub struct S081RuleOptions {
146    /// Whether files containing only a shebang are exempt from the rule.
147    pub ignore_shebang_only_files: bool,
148}
149
150/// Behavior overrides for `S082` TODO-style comment formatting.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct S082RuleOptions {
153    /// Comment markers that are checked at the start of a comment.
154    pub kinds: Vec<String>,
155    /// Whether a checked marker must be followed immediately by `(owner)`.
156    pub require_owner: bool,
157    /// Whether a checked marker must include non-empty explanatory text.
158    pub require_message: bool,
159}
160
161impl Default for S082RuleOptions {
162    fn default() -> Self {
163        Self {
164            kinds: vec!["TODO".to_owned(), "FIXME".to_owned(), "XXX".to_owned()],
165            require_owner: true,
166            require_message: true,
167        }
168    }
169}
170
171/// Which functions require a leading documentation comment for `S083`.
172#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
173pub enum S083FunctionDocRequirement {
174    All,
175    Exported,
176    #[default]
177    Long,
178    Parameterized,
179}
180
181/// Behavior overrides for `S083` missing function documentation.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct S083RuleOptions {
184    pub require_for: S083FunctionDocRequirement,
185    pub long_function_line_threshold: usize,
186}
187
188impl Default for S083RuleOptions {
189    fn default() -> Self {
190        Self {
191            require_for: S083FunctionDocRequirement::Long,
192            long_function_line_threshold: 10,
193        }
194    }
195}
196
197/// Behavior overrides for `S084` function documentation content.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct S084RuleOptions {
200    pub require_globals: bool,
201    pub require_arguments: bool,
202    pub require_outputs: bool,
203    pub require_returns: bool,
204}
205
206impl Default for S084RuleOptions {
207    fn default() -> Self {
208        Self {
209            require_globals: true,
210            require_arguments: true,
211            require_outputs: true,
212            require_returns: true,
213        }
214    }
215}
216
217/// Behavior overrides for `S085` main entrypoint analysis.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct S085RuleOptions {
220    /// Minimum source line count before the script is checked.
221    pub non_trivial_line_threshold: usize,
222    /// Minimum function definition count before the script is checked.
223    pub non_trivial_function_count: usize,
224    /// Expected entrypoint function name.
225    pub main_name: String,
226}
227
228impl Default for S085RuleOptions {
229    fn default() -> Self {
230        Self {
231            non_trivial_line_threshold: 30,
232            non_trivial_function_count: 2,
233            main_name: "main".to_owned(),
234        }
235    }
236}
237
238/// Behavior overrides for `C158` implicit global assignment analysis.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct C158RuleOptions {
241    /// Whether top-level readonly declarations document intentional globals.
242    pub treat_readonly_as_documented: bool,
243    /// Whether top-level exported bindings document intentional globals.
244    pub treat_export_as_intentional: bool,
245}
246
247impl Default for C158RuleOptions {
248    fn default() -> Self {
249        Self {
250            treat_readonly_as_documented: true,
251            treat_export_as_intentional: true,
252        }
253    }
254}
255
256/// Behavior overrides for `C159` mutable-global analysis.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct C159RuleOptions {
259    /// Whether self-referential default initializers such as `name=${name:-value}` are allowed.
260    pub allow_conditional_init: bool,
261}
262
263impl Default for C159RuleOptions {
264    fn default() -> Self {
265        Self {
266            allow_conditional_init: true,
267        }
268    }
269}
270
271/// Behavior overrides for `C160` unanchored source path analysis.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct C160RuleOptions {
274    /// Path prefix expressions accepted as script-directory anchors.
275    pub allowed_anchors: Vec<String>,
276}
277
278impl Default for C160RuleOptions {
279    fn default() -> Self {
280        Self {
281            allowed_anchors: DEFAULT_C160_ALLOWED_ANCHORS
282                .iter()
283                .map(|anchor| (*anchor).to_owned())
284                .collect(),
285        }
286    }
287}
288
289/// Behavior overrides for `C161` function-call ordering analysis.
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct C161RuleOptions {
292    /// Whether calls after a source command are ignored because sourced files may define functions.
293    pub ignore_after_source: bool,
294}
295
296impl Default for C161RuleOptions {
297    fn default() -> Self {
298        Self {
299            ignore_after_source: true,
300        }
301    }
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct LinterSettings {
306    pub rules: RuleSet,
307    pub severity_overrides: FxHashMap<Rule, Severity>,
308    pub shell: ShellDialect,
309    pub ambient_shell_options: AmbientShellOptions,
310    pub ambient_contracts: Arc<ResolvedAmbientContracts>,
311    pub analyzed_paths: Option<Arc<FxHashSet<PathBuf>>>,
312    pub per_file_ignores: Arc<CompiledPerFileIgnoreList>,
313    pub report_environment_style_names: bool,
314    pub resolve_source_closure: bool,
315    pub rule_options: LinterRuleOptions,
316}
317
318#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
319pub struct AmbientShellOptions {
320    pub errexit: bool,
321    pub pipefail: bool,
322}
323
324impl Default for LinterSettings {
325    fn default() -> Self {
326        Self {
327            rules: Self::default_rules(),
328            severity_overrides: FxHashMap::default(),
329            shell: ShellDialect::Unknown,
330            ambient_shell_options: AmbientShellOptions::default(),
331            ambient_contracts: Arc::new(ResolvedAmbientContracts::default()),
332            analyzed_paths: None,
333            per_file_ignores: Arc::new(CompiledPerFileIgnoreList::default()),
334            report_environment_style_names: false,
335            resolve_source_closure: true,
336            rule_options: LinterRuleOptions::default(),
337        }
338    }
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct PerFileIgnore {
343    pattern: String,
344    rules: RuleSet,
345}
346
347impl PerFileIgnore {
348    pub fn new(pattern: impl Into<String>, rules: RuleSet) -> Self {
349        Self {
350            pattern: pattern.into(),
351            rules,
352        }
353    }
354
355    pub fn pattern(&self) -> &str {
356        &self.pattern
357    }
358
359    pub const fn rules(&self) -> RuleSet {
360        self.rules
361    }
362}
363
364#[derive(Debug, Clone, Default)]
365pub struct CompiledPerFileIgnoreList {
366    project_root: PathBuf,
367    entries: Vec<CompiledPerFileIgnore>,
368}
369
370impl PartialEq for CompiledPerFileIgnoreList {
371    fn eq(&self, other: &Self) -> bool {
372        self.project_root == other.project_root && self.entries == other.entries
373    }
374}
375
376impl Eq for CompiledPerFileIgnoreList {}
377
378#[derive(Debug, Clone)]
379struct CompiledPerFileIgnore {
380    pattern: String,
381    basename_matcher: GlobMatcher,
382    relative_matcher: GlobMatcher,
383    absolute_matcher: GlobMatcher,
384    negated: bool,
385    rules: RuleSet,
386}
387
388impl PartialEq for CompiledPerFileIgnore {
389    fn eq(&self, other: &Self) -> bool {
390        self.pattern == other.pattern && self.negated == other.negated && self.rules == other.rules
391    }
392}
393
394impl Eq for CompiledPerFileIgnore {}
395
396impl LinterSettings {
397    pub fn for_rule(rule: Rule) -> Self {
398        Self {
399            rules: RuleSet::from_iter([rule]),
400            ..Self::default()
401        }
402    }
403
404    pub fn for_rules(rules: impl IntoIterator<Item = Rule>) -> Self {
405        Self {
406            rules: rules.into_iter().collect(),
407            ..Self::default()
408        }
409    }
410
411    pub fn default_rules() -> RuleSet {
412        Rule::iter()
413            .filter(|rule| !matches!(rule.category(), Category::Style))
414            .collect::<RuleSet>()
415            .subtract(&default_disabled_non_style_rules())
416    }
417
418    pub fn from_selectors(select: &[RuleSelector], ignore: &[RuleSelector]) -> Self {
419        let mut rules = RuleSet::EMPTY;
420        for selector in select {
421            rules = rules.union(&selector.into_rule_set());
422        }
423        for selector in ignore {
424            rules = rules.subtract(&selector.into_rule_set());
425        }
426
427        Self {
428            rules,
429            ..Self::default()
430        }
431    }
432
433    pub fn with_shell(mut self, shell: ShellDialect) -> Self {
434        self.shell = shell;
435        self
436    }
437
438    pub fn with_ambient_shell_options(
439        mut self,
440        ambient_shell_options: AmbientShellOptions,
441    ) -> Self {
442        self.ambient_shell_options = ambient_shell_options;
443        self
444    }
445
446    pub fn analyzed_path_set(paths: impl IntoIterator<Item = PathBuf>) -> Arc<FxHashSet<PathBuf>> {
447        Arc::new(
448            paths
449                .into_iter()
450                .map(|path| std::fs::canonicalize(&path).unwrap_or(path))
451                .collect(),
452        )
453    }
454
455    pub fn with_analyzed_path_set(mut self, paths: Arc<FxHashSet<PathBuf>>) -> Self {
456        self.analyzed_paths = Some(paths);
457        self
458    }
459
460    pub fn with_analyzed_paths(self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
461        self.with_analyzed_path_set(Self::analyzed_path_set(paths))
462    }
463
464    pub fn with_per_file_ignores(mut self, per_file_ignores: CompiledPerFileIgnoreList) -> Self {
465        self.per_file_ignores = Arc::new(per_file_ignores);
466        self
467    }
468
469    pub fn with_c001_treat_indirect_expansion_targets_as_used(mut self, value: bool) -> Self {
470        self.rule_options
471            .c001
472            .treat_indirect_expansion_targets_as_used = value;
473        self
474    }
475
476    pub fn with_resolve_source_closure(mut self, value: bool) -> Self {
477        self.resolve_source_closure = value;
478        self
479    }
480
481    pub fn with_c063_report_unreached_nested_definitions(mut self, value: bool) -> Self {
482        self.rule_options.c063.report_unreached_nested_definitions = value;
483        self
484    }
485
486    pub fn with_s080_max_lines(mut self, value: usize) -> Self {
487        self.rule_options.s080.max_lines = value;
488        self
489    }
490
491    pub fn with_s080_count(mut self, value: impl Into<String>) -> Self {
492        self.rule_options.s080.count = value.into();
493        self
494    }
495
496    pub fn with_s081_ignore_shebang_only_files(mut self, value: bool) -> Self {
497        self.rule_options.s081.ignore_shebang_only_files = value;
498        self
499    }
500
501    pub fn with_s082_kinds(mut self, kinds: impl IntoIterator<Item = String>) -> Self {
502        self.rule_options.s082.kinds = kinds.into_iter().collect();
503        self
504    }
505
506    pub fn with_s082_require_owner(mut self, value: bool) -> Self {
507        self.rule_options.s082.require_owner = value;
508        self
509    }
510
511    pub fn with_s082_require_message(mut self, value: bool) -> Self {
512        self.rule_options.s082.require_message = value;
513        self
514    }
515
516    pub fn with_s083_require_for(mut self, value: S083FunctionDocRequirement) -> Self {
517        self.rule_options.s083.require_for = value;
518        self
519    }
520
521    pub fn with_s083_long_function_line_threshold(mut self, value: usize) -> Self {
522        self.rule_options.s083.long_function_line_threshold = value;
523        self
524    }
525
526    pub fn with_s084_require_globals(mut self, value: bool) -> Self {
527        self.rule_options.s084.require_globals = value;
528        self
529    }
530
531    pub fn with_s084_require_arguments(mut self, value: bool) -> Self {
532        self.rule_options.s084.require_arguments = value;
533        self
534    }
535
536    pub fn with_s084_require_outputs(mut self, value: bool) -> Self {
537        self.rule_options.s084.require_outputs = value;
538        self
539    }
540
541    pub fn with_s084_require_returns(mut self, value: bool) -> Self {
542        self.rule_options.s084.require_returns = value;
543        self
544    }
545
546    pub fn with_s085_non_trivial_line_threshold(mut self, value: usize) -> Self {
547        self.rule_options.s085.non_trivial_line_threshold = value;
548        self
549    }
550
551    pub fn with_s085_non_trivial_function_count(mut self, value: usize) -> Self {
552        self.rule_options.s085.non_trivial_function_count = value;
553        self
554    }
555
556    pub fn with_s085_main_name(mut self, value: impl Into<String>) -> Self {
557        self.rule_options.s085.main_name = value.into();
558        self
559    }
560
561    pub fn with_s078_allowed_shells(
562        mut self,
563        allowed_shells: impl IntoIterator<Item = impl Into<String>>,
564    ) -> Self {
565        self.rule_options.s078.allowed_shells =
566            allowed_shells.into_iter().map(Into::into).collect();
567        self
568    }
569
570    pub fn with_c158_treat_readonly_as_documented(mut self, value: bool) -> Self {
571        self.rule_options.c158.treat_readonly_as_documented = value;
572        self
573    }
574
575    pub fn with_c158_treat_export_as_intentional(mut self, value: bool) -> Self {
576        self.rule_options.c158.treat_export_as_intentional = value;
577        self
578    }
579
580    pub fn with_c159_allow_conditional_init(mut self, value: bool) -> Self {
581        self.rule_options.c159.allow_conditional_init = value;
582        self
583    }
584
585    pub fn with_c160_allowed_anchors<I, S>(mut self, anchors: I) -> Self
586    where
587        I: IntoIterator<Item = S>,
588        S: Into<String>,
589    {
590        self.rule_options.c160.allowed_anchors = anchors.into_iter().map(Into::into).collect();
591        self
592    }
593
594    pub fn with_c161_ignore_after_source(mut self, value: bool) -> Self {
595        self.rule_options.c161.ignore_after_source = value;
596        self
597    }
598
599    pub fn with_s079_allowed_forms(
600        mut self,
601        allowed_forms: impl IntoIterator<Item = impl Into<String>>,
602    ) -> Self {
603        self.rule_options.s079.allowed_forms = allowed_forms.into_iter().map(Into::into).collect();
604        self
605    }
606
607    pub fn with_s079_allowed_paths(
608        mut self,
609        allowed_paths: impl IntoIterator<Item = impl Into<String>>,
610    ) -> Self {
611        self.rule_options.s079.allowed_paths = allowed_paths.into_iter().map(Into::into).collect();
612        self
613    }
614
615    pub fn per_file_ignored_rules(&self, path: Option<&Path>) -> RuleSet {
616        path.map_or(RuleSet::EMPTY, |path| {
617            self.per_file_ignores.ignored_rules(path)
618        })
619    }
620}
621
622fn default_disabled_non_style_rules() -> RuleSet {
623    DEFAULT_DISABLED_NON_STYLE_RULES.iter().copied().collect()
624}
625
626impl CompiledPerFileIgnoreList {
627    pub fn resolve(
628        project_root: impl Into<PathBuf>,
629        per_file_ignores: impl IntoIterator<Item = PerFileIgnore>,
630    ) -> Result<Self> {
631        let project_root = project_root.into();
632        let entries = per_file_ignores
633            .into_iter()
634            .map(|per_file_ignore| {
635                let mut pattern = per_file_ignore.pattern().to_owned();
636                let negated = pattern.starts_with('!');
637                if negated {
638                    pattern.drain(..1);
639                }
640
641                let basename_matcher = Glob::new(&pattern)
642                    .with_context(|| format!("invalid glob {:?}", per_file_ignore.pattern()))?
643                    .compile_matcher();
644                let relative_matcher = Glob::new(&pattern)
645                    .with_context(|| format!("invalid glob {:?}", per_file_ignore.pattern()))?
646                    .compile_matcher();
647                let absolute_matcher = Glob::new(&pattern)
648                    .with_context(|| format!("invalid glob {:?}", per_file_ignore.pattern()))?
649                    .compile_matcher();
650
651                Ok(CompiledPerFileIgnore {
652                    pattern: per_file_ignore.pattern().to_owned(),
653                    basename_matcher,
654                    relative_matcher,
655                    absolute_matcher,
656                    negated,
657                    rules: per_file_ignore.rules(),
658                })
659            })
660            .collect::<Result<Vec<_>>>()?;
661
662        Ok(Self {
663            project_root,
664            entries,
665        })
666    }
667
668    pub fn is_empty(&self) -> bool {
669        self.entries.is_empty()
670    }
671
672    pub fn ignored_rules(&self, path: &Path) -> RuleSet {
673        let relative_path = path.strip_prefix(&self.project_root).unwrap_or(path);
674        let file_name = relative_path.file_name().or_else(|| path.file_name());
675        let Some(file_name) = file_name else {
676            return RuleSet::EMPTY;
677        };
678
679        self.entries.iter().fold(RuleSet::EMPTY, |ignored, entry| {
680            let matches = entry.basename_matcher.is_match(file_name)
681                || entry.relative_matcher.is_match(relative_path)
682                || matches_absolute_path(&entry.absolute_matcher, path);
683            let applies = if entry.negated { !matches } else { matches };
684
685            if applies {
686                ignored.union(&entry.rules)
687            } else {
688                ignored
689            }
690        })
691    }
692}
693
694fn matches_absolute_path(matcher: &GlobMatcher, path: &Path) -> bool {
695    matcher.is_match(path)
696        || normalized_absolute_match_path(path)
697            .as_deref()
698            .is_some_and(|normalized| matcher.is_match(normalized))
699}
700
701fn normalized_absolute_match_path(path: &Path) -> Option<PathBuf> {
702    let path = path.to_string_lossy();
703
704    if let Some(stripped) = path.strip_prefix(r"\\?\UNC\") {
705        return Some(PathBuf::from(format!(r"\\{stripped}")));
706    }
707
708    path.strip_prefix(r"\\?\").map(PathBuf::from)
709}
710
711#[cfg(test)]
712mod tests {
713    use std::path::{Path, PathBuf};
714
715    use tempfile::tempdir;
716
717    use super::*;
718    use crate::RuleSet;
719
720    #[test]
721    fn default_rules_exclude_all_style_rules() {
722        let defaults = LinterSettings::default_rules();
723
724        for rule in Rule::iter().filter(|rule| matches!(rule.category(), Category::Style)) {
725            assert!(
726                !defaults.contains(rule),
727                "{rule:?} should be disabled by default"
728            );
729        }
730    }
731
732    #[test]
733    fn default_rules_include_non_style_rules() {
734        let defaults = LinterSettings::default_rules();
735
736        assert!(defaults.contains(Rule::UndefinedVariable));
737        assert!(defaults.contains(Rule::ConstantCaseSubject));
738        assert!(defaults.contains(Rule::RmGlobOnVariablePath));
739        assert!(!defaults.contains(Rule::ImplicitGlobalInFunction));
740        assert!(!defaults.contains(Rule::MutableGlobal));
741        assert!(!defaults.contains(Rule::UnanchoredSourcePath));
742        assert!(!defaults.contains(Rule::FunctionCalledBeforeDefined));
743        assert!(!defaults.contains(Rule::AmpersandSemicolon));
744    }
745
746    #[test]
747    fn default_rules_exclude_verified_default_disabled_non_style_rules() {
748        let defaults = LinterSettings::default_rules();
749
750        for rule in DEFAULT_DISABLED_NON_STYLE_RULES {
751            assert!(
752                !defaults.contains(*rule),
753                "{rule:?} should be excluded from the native default baseline"
754            );
755            assert!(
756                !matches!(rule.category(), Category::Style),
757                "{rule:?} must stay in the non-style default-disabled set"
758            );
759        }
760    }
761
762    #[test]
763    fn with_analyzed_path_set_reuses_shared_set() {
764        let tempdir = tempdir().unwrap();
765        let script_path = tempdir.path().join("script.sh");
766        std::fs::write(&script_path, "echo hi\n").unwrap();
767
768        let analyzed_paths = LinterSettings::analyzed_path_set([script_path.clone()]);
769        let settings =
770            LinterSettings::default().with_analyzed_path_set(Arc::clone(&analyzed_paths));
771
772        let stored = settings.analyzed_paths.as_ref().unwrap();
773        assert!(Arc::ptr_eq(stored, &analyzed_paths));
774        assert!(stored.contains(&std::fs::canonicalize(script_path).unwrap()));
775    }
776
777    #[test]
778    fn matches_absolute_per_file_ignore_patterns() {
779        let tempdir = tempdir().unwrap();
780        let project_root = tempdir.path().to_path_buf();
781        let script_path = project_root.join("nested").join("script.sh");
782        let absolute_pattern = script_path
783            .parent()
784            .unwrap()
785            .join("*.sh")
786            .to_string_lossy()
787            .into_owned();
788        let per_file_ignores = CompiledPerFileIgnoreList::resolve(
789            project_root,
790            [PerFileIgnore::new(
791                absolute_pattern,
792                RuleSet::from_iter([Rule::UnusedAssignment]),
793            )],
794        )
795        .unwrap();
796
797        let ignored_rules = per_file_ignores.ignored_rules(&script_path);
798
799        assert!(ignored_rules.contains(Rule::UnusedAssignment));
800    }
801
802    #[test]
803    fn strips_windows_verbatim_disk_prefixes_for_absolute_matching() {
804        assert_eq!(
805            normalized_absolute_match_path(Path::new(r"\\?\C:\repo\nested\script.sh")),
806            Some(PathBuf::from(r"C:\repo\nested\script.sh"))
807        );
808    }
809
810    #[test]
811    fn strips_windows_verbatim_unc_prefixes_for_absolute_matching() {
812        assert_eq!(
813            normalized_absolute_match_path(Path::new(r"\\?\UNC\server\share\script.sh")),
814            Some(PathBuf::from(r"\\server\share\script.sh"))
815        );
816    }
817}