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#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct LinterRuleOptions {
29 pub c001: C001RuleOptions,
31 pub c063: C063RuleOptions,
33 pub s078: S078RuleOptions,
35 pub s079: S079RuleOptions,
37 pub s080: S080RuleOptions,
39 pub s081: S081RuleOptions,
41 pub s082: S082RuleOptions,
43 pub s083: S083RuleOptions,
45 pub s084: S084RuleOptions,
47 pub s085: S085RuleOptions,
49 pub c158: C158RuleOptions,
51 pub c159: C159RuleOptions,
53 pub c160: C160RuleOptions,
55 pub c161: C161RuleOptions,
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct C001RuleOptions {
62 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct C063RuleOptions {
80 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#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct S080RuleOptions {
95 pub max_lines: usize,
97 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#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct S078RuleOptions {
113 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#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct S079RuleOptions {
128 pub allowed_forms: Vec<String>,
130 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
145pub struct S081RuleOptions {
146 pub ignore_shebang_only_files: bool,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct S082RuleOptions {
153 pub kinds: Vec<String>,
155 pub require_owner: bool,
157 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
173pub enum S083FunctionDocRequirement {
174 All,
175 Exported,
176 #[default]
177 Long,
178 Parameterized,
179}
180
181#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct S085RuleOptions {
220 pub non_trivial_line_threshold: usize,
222 pub non_trivial_function_count: usize,
224 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#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct C158RuleOptions {
241 pub treat_readonly_as_documented: bool,
243 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#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct C159RuleOptions {
259 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#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct C160RuleOptions {
274 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#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct C161RuleOptions {
292 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}