Skip to main content

mq_lang/module/
resolver.rs

1#[cfg(feature = "http-import")]
2pub(crate) mod http_resolver;
3pub(crate) mod local_fs_resolver;
4pub(crate) mod std_resolver;
5
6use crate::module::error::ModuleError;
7use std::path::PathBuf;
8
9/// Core interface for resolving mq module source code by name.
10pub trait ModuleResolver: Clone + Default {
11    /// Returns the source content of `module_name`.
12    fn resolve(&self, module_name: &str) -> Result<String, ModuleError>;
13    /// Returns the canonical path string for `module_name` (for diagnostics / LSP).
14    fn get_path(&self, module_name: &str) -> Result<String, ModuleError>;
15    /// Returns the filesystem directories this resolver searches.
16    fn search_paths(&self) -> Vec<PathBuf>;
17    /// Replaces the filesystem search directories.
18    fn set_search_paths(&mut self, paths: Vec<PathBuf>);
19    /// Returns the short identifier to store the module under.
20    ///
21    /// For most resolvers this is `module_path` unchanged.  HTTP-based resolvers
22    /// strip the URL prefix and `.mq` suffix so that, for example,
23    /// `github.com/alice/mymod.mq@v1.0` becomes `"mymod"`.
24    fn canonical_name<'a>(&self, module_path: &'a str) -> &'a str {
25        module_path
26    }
27}
28
29/// The default resolver, combining standard library, local filesystem, and (optionally) HTTP sources.
30#[derive(Debug, Clone, Default)]
31pub struct DefaultModuleResolver {
32    local_fs_resolver: local_fs_resolver::LocalFsModuleResolver,
33    std_resolver: std_resolver::StdModuleResolver,
34    #[cfg(feature = "http-import")]
35    http_resolver: http_resolver::HttpModuleResolver,
36}
37
38impl ModuleResolver for DefaultModuleResolver {
39    fn resolve(&self, module_name: &str) -> Result<String, ModuleError> {
40        match self.std_resolver.resolve(module_name) {
41            Ok(content) => return Ok(content),
42            Err(ModuleError::NotFound(_)) => {}
43            Err(e) => return Err(e),
44        }
45
46        match self.local_fs_resolver.resolve(module_name) {
47            Ok(content) => return Ok(content),
48            Err(ModuleError::NotFound(_)) => {}
49            Err(e) => return Err(e),
50        }
51
52        #[cfg(feature = "http-import")]
53        match self.http_resolver.resolve(module_name) {
54            Ok(content) => return Ok(content),
55            Err(ModuleError::NotFound(_)) => {}
56            Err(e) => return Err(e),
57        }
58
59        Err(ModuleError::NotFound(format!("{}.mq", module_name).into()))
60    }
61
62    fn get_path(&self, module_name: &str) -> Result<String, ModuleError> {
63        match self.std_resolver.get_path(module_name) {
64            Ok(path) => return Ok(path),
65            Err(ModuleError::NotFound(_)) => {}
66            Err(e) => return Err(e),
67        }
68
69        match self.local_fs_resolver.get_path(module_name) {
70            Ok(path) => return Ok(path),
71            Err(ModuleError::NotFound(_)) => {}
72            Err(e) => return Err(e),
73        }
74
75        #[cfg(feature = "http-import")]
76        match self.http_resolver.get_path(module_name) {
77            Ok(path) => return Ok(path),
78            Err(ModuleError::NotFound(_)) => {}
79            Err(e) => return Err(e),
80        }
81
82        Err(ModuleError::NotFound(format!("{}.mq", module_name).into()))
83    }
84
85    fn search_paths(&self) -> Vec<PathBuf> {
86        self.local_fs_resolver.search_paths()
87    }
88
89    fn set_search_paths(&mut self, paths: Vec<PathBuf>) {
90        self.local_fs_resolver.set_search_paths(paths)
91    }
92
93    fn canonical_name<'a>(&self, module_path: &'a str) -> &'a str {
94        #[cfg(feature = "http-import")]
95        {
96            use http_resolver::HttpModuleResolver;
97            if HttpModuleResolver::is_github_url(module_path) || HttpModuleResolver::is_remote_url(module_path) {
98                return self.http_resolver.canonical_name(module_path);
99            }
100        }
101        module_path
102    }
103}
104
105impl DefaultModuleResolver {
106    /// Creates a new resolver with the given filesystem search paths.
107    ///
108    /// An empty `paths` slice falls back to the built-in default search directories.
109    pub fn new(paths: Vec<PathBuf>) -> Self {
110        Self {
111            local_fs_resolver: local_fs_resolver::LocalFsModuleResolver::new(if paths.is_empty() {
112                None
113            } else {
114                Some(paths)
115            }),
116            std_resolver: std_resolver::StdModuleResolver,
117            #[cfg(feature = "http-import")]
118            http_resolver: http_resolver::HttpModuleResolver::default(),
119        }
120    }
121
122    /// Configures the HTTP resolver with a domain allowlist and request timeout.
123    ///
124    /// An empty `allowed_domains` list restricts access to the built-in default domain
125    /// (`raw.githubusercontent.com/harehare`) only; it does not open up all URLs.
126    /// Only available when the `http-import` feature is enabled.
127    #[cfg(feature = "http-import")]
128    pub fn with_http(mut self, allowed_domains: Vec<String>, timeout: Option<std::time::Duration>) -> Self {
129        self.http_resolver = http_resolver::HttpModuleResolver::new(
130            allowed_domains,
131            timeout.unwrap_or(std::time::Duration::from_secs(10)),
132        );
133        self
134    }
135
136    /// Replaces the HTTP resolver's domain allowlist.
137    ///
138    /// An empty list restricts access to the built-in default domain
139    /// (`raw.githubusercontent.com/harehare`) only.
140    ///
141    /// Entries in the form `github.com/{user}/{repo}` are automatically expanded to
142    /// `raw.githubusercontent.com/{user}/{repo}`.
143    #[cfg(feature = "http-import")]
144    pub fn set_allowed_domains(&mut self, domains: Vec<String>) {
145        self.http_resolver.allowed_remote_domains = domains
146            .into_iter()
147            .map(|d| http_resolver::HttpModuleResolver::normalize_allowed_domain(&d))
148            .collect();
149    }
150
151    /// Clears all locally-cached HTTP module files.
152    ///
153    /// Call this once before processing to force a re-fetch of all cached modules
154    /// on the next resolve.
155    #[cfg(feature = "http-import")]
156    pub fn clear_http_cache(&self) -> Result<(), crate::module::error::ModuleError> {
157        self.http_resolver.clear_cache()
158    }
159
160    /// Clears all HTTP module cache including versioned modules and lock files.
161    #[cfg(feature = "http-import")]
162    pub fn clear_http_cache_all(&self) -> Result<(), crate::module::error::ModuleError> {
163        self.http_resolver.clear_all_cache()
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use rstest::rstest;
170    use tempfile::TempDir;
171
172    use super::*;
173
174    fn write_module(dir: &TempDir, name: &str, content: &str) {
175        std::fs::write(dir.path().join(format!("{}.mq", name)), content).unwrap();
176    }
177
178    #[rstest]
179    #[case("csv")]
180    #[case("json")]
181    #[case("yaml")]
182    #[case("toml")]
183    fn test_resolve_standard_module(#[case] name: &str) {
184        let resolver = DefaultModuleResolver::default();
185        assert!(resolver.resolve(name).is_ok());
186    }
187
188    #[rstest]
189    #[case("csv")]
190    #[case("json")]
191    fn test_get_path_standard_module(#[case] name: &str) {
192        let resolver = DefaultModuleResolver::default();
193        assert!(resolver.get_path(name).is_ok());
194    }
195
196    #[rstest]
197    #[case("nonexistent_xyz")]
198    fn test_resolve_unknown_module_returns_error(#[case] name: &str) {
199        let resolver = DefaultModuleResolver::new(vec![]);
200        assert!(resolver.resolve(name).is_err());
201    }
202
203    #[test]
204    fn test_resolve_local_module() {
205        let dir = TempDir::new().unwrap();
206        write_module(&dir, "mymod", "def foo(): 1;");
207
208        let resolver = DefaultModuleResolver::new(vec![dir.path().to_path_buf()]);
209        assert!(resolver.resolve("mymod").is_ok());
210    }
211
212    #[test]
213    fn test_std_takes_priority_over_local() {
214        let dir = TempDir::new().unwrap();
215        write_module(&dir, "csv", "def foo(): 1;");
216
217        let resolver = DefaultModuleResolver::new(vec![dir.path().to_path_buf()]);
218        // standard module should win over local file with the same name
219        let content = resolver.resolve("csv").unwrap();
220        assert!(!content.contains("def foo(): 1;"));
221    }
222
223    #[test]
224    fn test_search_paths_empty_uses_defaults() {
225        let resolver = DefaultModuleResolver::new(vec![]);
226        assert!(!resolver.search_paths().is_empty());
227    }
228
229    #[test]
230    fn test_search_paths_custom() {
231        let paths = vec![PathBuf::from("/custom")];
232        let resolver = DefaultModuleResolver::new(paths.clone());
233        assert_eq!(resolver.search_paths(), paths);
234    }
235
236    #[test]
237    fn test_set_search_paths() {
238        let mut resolver = DefaultModuleResolver::new(vec![]);
239        let paths = vec![PathBuf::from("/new")];
240        resolver.set_search_paths(paths.clone());
241        assert_eq!(resolver.search_paths(), paths);
242    }
243
244    #[cfg(feature = "http-import")]
245    #[rstest]
246    #[case("https://nonexistent.invalid/foo.mq")]
247    fn test_http_url_not_in_local(#[case] url: &str) {
248        // Without an HTTP resolver configured, should fall through to error
249        let resolver = DefaultModuleResolver::new(vec![]);
250        // Either network error or module-not-found; should not panic
251        assert!(resolver.resolve(url).is_err());
252    }
253
254    #[cfg(feature = "http-import")]
255    #[test]
256    fn test_with_http_normalizes_github_domains() {
257        // with_http delegates to HttpModuleResolver::new which normalizes github.com/* entries
258        let resolver = DefaultModuleResolver::new(vec![]).with_http(vec!["github.com/alice/myrepo".to_string()], None);
259        assert!(
260            resolver
261                .http_resolver
262                .is_allowed_domain("https://raw.githubusercontent.com/alice/myrepo/HEAD/mod.mq")
263        );
264        assert!(
265            !resolver
266                .http_resolver
267                .is_allowed_domain("https://raw.githubusercontent.com/alice/other/HEAD/mod.mq")
268        );
269    }
270
271    #[cfg(feature = "http-import")]
272    #[test]
273    fn test_set_allowed_domains_normalizes_github_domains() {
274        let mut resolver = DefaultModuleResolver::new(vec![]);
275        resolver.set_allowed_domains(vec!["github.com/bob/myrepo".to_string()]);
276        assert!(
277            resolver
278                .http_resolver
279                .is_allowed_domain("https://raw.githubusercontent.com/bob/myrepo/HEAD/mod.mq")
280        );
281        assert!(
282            !resolver
283                .http_resolver
284                .is_allowed_domain("https://raw.githubusercontent.com/bob/other/HEAD/mod.mq")
285        );
286    }
287}