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}