fob_graph/analysis/resolver/
mod.rs

1//! Module resolution for standalone analysis.
2//!
3//! Implements Node.js-style module resolution algorithm without requiring
4//! the full bundler infrastructure.
5
6mod algorithm;
7mod aliases;
8mod extensions;
9
10pub use algorithm::{is_external, resolve_local, resolve_with_alias};
11pub use aliases::resolve_path_alias;
12pub use extensions::{EXTENSIONS, resolve_with_extensions};
13
14use std::path::{Path, PathBuf};
15
16use crate::runtime::{Runtime, RuntimeError};
17
18use crate::analysis::config::{AnalyzerConfig, ResolveResult};
19
20/// Module resolver for standalone analysis.
21pub struct ModuleResolver {
22    config: AnalyzerConfig,
23}
24
25impl ModuleResolver {
26    /// Create a new module resolver with the given configuration.
27    pub fn new(config: AnalyzerConfig) -> Self {
28        Self { config }
29    }
30
31    /// Resolve a module specifier from a given file.
32    ///
33    /// Implements Node.js resolution algorithm:
34    /// 1. Check if it's a relative/absolute path
35    /// 2. Try path aliases
36    /// 3. Try extensions (.ts, .tsx, .js, .jsx)
37    /// 4. Try index files
38    /// 5. Check if it's external (npm package)
39    pub async fn resolve(
40        &self,
41        specifier: &str,
42        from: &Path,
43        runtime: &dyn Runtime,
44    ) -> Result<ResolveResult, RuntimeError> {
45        // Check if it's explicitly external
46        if algorithm::is_external(specifier, &self.config.external) {
47            return Ok(ResolveResult::External(specifier.to_string()));
48        }
49
50        // Check path aliases first
51        let cwd = self.get_cwd(runtime)?;
52        if let Some(resolved) = algorithm::resolve_with_alias(
53            specifier,
54            from,
55            cwd.as_path(),
56            &self.config.path_aliases,
57            runtime,
58        )
59        .await?
60        {
61            return Ok(resolved);
62        }
63
64        // Try direct resolution
65        if specifier.starts_with('.') || specifier.starts_with('/') {
66            // Relative or absolute path
67            return algorithm::resolve_local(specifier, from, self.config.cwd.as_deref(), runtime)
68                .await;
69        }
70
71        // Must be an external package (bare import)
72        Ok(ResolveResult::External(specifier.to_string()))
73    }
74
75    /// Get the current working directory, preferring config, then runtime.
76    pub fn get_cwd(&self, runtime: &dyn Runtime) -> Result<PathBuf, RuntimeError> {
77        if let Some(ref cwd) = self.config.cwd {
78            Ok(cwd.clone())
79        } else {
80            runtime.get_cwd()
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::runtime::RuntimeError;
89    use std::path::PathBuf;
90
91    // Mock runtime for testing
92    #[derive(Debug)]
93    struct MockRuntime {
94        files: Vec<PathBuf>,
95    }
96
97    #[cfg(not(target_family = "wasm"))]
98    #[async_trait::async_trait]
99    impl Runtime for MockRuntime {
100        fn exists(&self, path: &Path) -> bool {
101            self.files.iter().any(|f| f == path)
102        }
103
104        async fn read_file(&self, _path: &Path) -> Result<Vec<u8>, RuntimeError> {
105            Err(RuntimeError::FileNotFound(PathBuf::new()))
106        }
107
108        async fn write_file(&self, _path: &Path, _content: &[u8]) -> Result<(), RuntimeError> {
109            Ok(())
110        }
111
112        async fn metadata(
113            &self,
114            path: &Path,
115        ) -> Result<crate::runtime::FileMetadata, RuntimeError> {
116            if self.files.iter().any(|f| f == path) {
117                Ok(crate::runtime::FileMetadata {
118                    is_file: true,
119                    is_dir: false,
120                    size: 0,
121                    modified: None,
122                })
123            } else {
124                Err(RuntimeError::FileNotFound(path.to_path_buf()))
125            }
126        }
127
128        fn resolve(&self, _specifier: &str, _from: &Path) -> Result<PathBuf, RuntimeError> {
129            Err(RuntimeError::FileNotFound(PathBuf::new()))
130        }
131
132        async fn create_dir(&self, _path: &Path, _recursive: bool) -> Result<(), RuntimeError> {
133            Ok(())
134        }
135
136        async fn remove_file(&self, _path: &Path) -> Result<(), RuntimeError> {
137            Ok(())
138        }
139
140        async fn read_dir(&self, _path: &Path) -> Result<Vec<String>, RuntimeError> {
141            Ok(vec![])
142        }
143
144        fn get_cwd(&self) -> Result<PathBuf, RuntimeError> {
145            Ok(PathBuf::from("/test"))
146        }
147    }
148
149    #[tokio::test]
150    async fn test_resolve_relative() {
151        let mut config = AnalyzerConfig::default();
152        config.cwd = Some(PathBuf::from("/test"));
153
154        let runtime = MockRuntime {
155            files: vec![PathBuf::from("/test/src/utils.ts")],
156        };
157
158        let resolver = ModuleResolver::new(config);
159        let from = PathBuf::from("/test/src/index.ts");
160
161        let result = resolver.resolve("./utils", &from, &runtime).await.unwrap();
162        assert!(matches!(result, ResolveResult::Local(_)));
163    }
164
165    #[tokio::test]
166    async fn test_resolve_external() {
167        let mut config = AnalyzerConfig::default();
168        config.external = vec!["react".to_string()];
169
170        let runtime = MockRuntime { files: vec![] };
171        let resolver = ModuleResolver::new(config);
172        let from = PathBuf::from("/test/src/index.ts");
173
174        let result = resolver.resolve("react", &from, &runtime).await.unwrap();
175        assert!(matches!(result, ResolveResult::External(_)));
176    }
177}