lychee_lib/types/input/resolver.rs
1//! Input source resolution.
2//!
3//! Provides the `InputResolver` which handles resolution of various input sources
4//! into concrete, processable sources by expanding glob patterns and applying filters.
5
6use super::input::Input;
7use super::source::{InputSource, ResolvedInputSource};
8use crate::Result;
9use crate::filter::PathExcludes;
10use crate::types::file::FileExtensions;
11use async_stream::try_stream;
12use futures::stream::Stream;
13use glob::glob_with;
14use ignore::WalkBuilder;
15use shellexpand::tilde;
16
17/// Resolves input sources into concrete, processable sources.
18///
19/// Handles expansion of glob patterns and filtering based on exclusion rules.
20#[derive(Copy, Clone, Debug)]
21pub struct InputResolver;
22
23impl InputResolver {
24 /// Resolve an input into a stream of concrete input sources.
25 ///
26 /// This returns a stream of resolved input sources for the given input,
27 /// taking into account the matching file extensions and respecting
28 /// exclusions. Glob patterns are expanded into individual file paths.
29 ///
30 /// # Returns
31 ///
32 /// Returns a stream of `Result<ResolvedInputSource>` for all matching input
33 /// sources. Glob patterns are expanded, so `FsGlob` never appears in the
34 /// output.
35 ///
36 /// # Errors
37 ///
38 /// Will return errors for file system operations or glob pattern issues
39 pub fn resolve<'a>(
40 input: &'a Input,
41 file_extensions: FileExtensions,
42 skip_hidden: bool,
43 skip_gitignored: bool,
44 excluded_paths: &'a PathExcludes,
45 ) -> impl Stream<Item = Result<ResolvedInputSource>> + 'a {
46 Self::resolve_input(
47 input,
48 file_extensions,
49 skip_hidden,
50 skip_gitignored,
51 excluded_paths,
52 )
53 }
54
55 /// Internal method for resolving input sources.
56 ///
57 /// Takes an Input and returns a stream of `ResolvedInputSource` items,
58 /// expanding glob patterns and applying filtering based on the provided
59 /// configuration.
60 fn resolve_input<'a>(
61 input: &'a Input,
62 file_extensions: FileExtensions,
63 skip_hidden: bool,
64 skip_gitignored: bool,
65 excluded_paths: &'a PathExcludes,
66 ) -> impl Stream<Item = Result<ResolvedInputSource>> + 'a {
67 try_stream! {
68 match &input.source {
69 InputSource::RemoteUrl(url) => {
70 yield ResolvedInputSource::RemoteUrl(url.clone());
71 },
72 InputSource::FsGlob { pattern, ignore_case } => {
73 // For glob patterns, we expand the pattern and yield
74 // matching paths as ResolvedInputSource::FsPath items.
75 let glob_expanded = tilde(pattern).to_string();
76 let mut match_opts = glob::MatchOptions::new();
77 match_opts.case_sensitive = !ignore_case;
78
79 for entry in glob_with(&glob_expanded, match_opts)? {
80 match entry {
81 Ok(path) => {
82 // Skip directories or files that don't match
83 // extensions
84 if path.is_dir() {
85 continue;
86 }
87 if excluded_paths.is_match(&path.to_string_lossy()) {
88 continue;
89 }
90
91 // We do not filter by extensions here.
92 //
93 // Instead, we always check files captured by
94 // the glob pattern, as the user explicitly
95 // specified them.
96 yield ResolvedInputSource::FsPath(path);
97 }
98 Err(e) => {
99 eprintln!("Error in glob pattern: {e:?}");
100 }
101 }
102 }
103 },
104 InputSource::FsPath(path) => {
105 if path.is_dir() {
106 let walk = WalkBuilder::new(path)
107 // Enable standard filters if `skip_gitignored `is
108 // true. This will skip files ignored by
109 // `.gitignore` and other VCS ignore files.
110 .standard_filters(skip_gitignored)
111 // Override hidden file behavior to be controlled by
112 // the separate skip_hidden parameter
113 .hidden(skip_hidden)
114 // Configure the file types filter to only include
115 // files with matching extensions
116 .types(file_extensions.try_into()?)
117 .build();
118
119 for entry in walk {
120 let entry = entry?;
121 if excluded_paths.is_match(&entry.path().to_string_lossy()) {
122 continue;
123 }
124
125 match entry.file_type() {
126 None => continue,
127 Some(file_type) => {
128 if !file_type.is_file() {
129 continue;
130 }
131 }
132 }
133
134 yield ResolvedInputSource::FsPath(entry.path().to_path_buf());
135 }
136 } else {
137 // For individual files, yield if not excluded.
138 //
139 // We do not filter by extension here, as individual
140 // files should always be checked, no matter if their
141 // extension matches or not.
142 //
143 // This follows the principle of least surprise because
144 // the user explicitly specified the file, so they
145 // expect it to be checked.
146 if !excluded_paths.is_match(&path.to_string_lossy()) {
147 yield ResolvedInputSource::FsPath(path.clone());
148 }
149 }
150 },
151 InputSource::Stdin => {
152 yield ResolvedInputSource::Stdin;
153 },
154 InputSource::String(s) => {
155 yield ResolvedInputSource::String(s.clone());
156 }
157 }
158 }
159 }
160}