/ src / analysis / module_resolver.rs
module_resolver.rs
  1  use crate::languages::LanguageSupport;
  2  use std::path::{Path, PathBuf};
  3  use tower_lsp::lsp_types::Url;
  4  
  5  #[derive(Debug, Clone)]
  6  pub struct ModuleResolver {
  7      workspace_root: PathBuf,
  8  }
  9  
 10  impl ModuleResolver {
 11      pub fn new(workspace_root: PathBuf) -> Self {
 12          Self { workspace_root }
 13      }
 14  
 15      pub fn workspace_root(&self) -> &Path {
 16          &self.workspace_root
 17      }
 18  
 19      pub fn resolve(
 20          &self,
 21          specifier: &str,
 22          from_uri: &Url,
 23          language: &dyn LanguageSupport,
 24      ) -> Option<PathBuf> {
 25          if !specifier.starts_with("./") && !specifier.starts_with("../") {
 26              return None;
 27          }
 28  
 29          let from_path = from_uri.to_file_path().ok()?;
 30          let from_dir = from_path.parent()?;
 31          let base_path = from_dir.join(specifier);
 32  
 33          let normalized = normalize_path(&base_path);
 34  
 35          if !normalized.starts_with(&self.workspace_root) {
 36              return None;
 37          }
 38  
 39          self.resolve_with_extensions(&normalized, language)
 40      }
 41  
 42      pub fn resolve_to_uri(
 43          &self,
 44          specifier: &str,
 45          from_uri: &Url,
 46          language: &dyn LanguageSupport,
 47      ) -> Option<Url> {
 48          let path = self.resolve(specifier, from_uri, language)?;
 49          Url::from_file_path(path).ok()
 50      }
 51  
 52      fn resolve_with_extensions(
 53          &self,
 54          base_path: &Path,
 55          language: &dyn LanguageSupport,
 56      ) -> Option<PathBuf> {
 57          if base_path.exists() && base_path.is_file() {
 58              return Some(base_path.to_path_buf());
 59          }
 60  
 61          let base_str = base_path.to_string_lossy();
 62          for ext in language.extensions() {
 63              let with_ext = PathBuf::from(format!("{}.{}", base_str, ext));
 64              if with_ext.exists() {
 65                  return Some(with_ext);
 66              }
 67          }
 68  
 69          if base_path.is_dir() || !base_path.exists() {
 70              for ext in language.extensions() {
 71                  let index_path = base_path.join(format!("index.{}", ext));
 72                  if index_path.exists() {
 73                      return Some(index_path);
 74                  }
 75              }
 76          }
 77  
 78          None
 79      }
 80  
 81      #[inline]
 82      pub fn is_relative_import(specifier: &str) -> bool {
 83          specifier.starts_with("./") || specifier.starts_with("../")
 84      }
 85  
 86      #[inline]
 87      pub fn is_package_import(specifier: &str) -> bool {
 88          !specifier.starts_with("./") && !specifier.starts_with("../") && !specifier.starts_with('/')
 89      }
 90  }
 91  
 92  fn normalize_path(path: &Path) -> PathBuf {
 93      let mut components = Vec::new();
 94  
 95      for component in path.components() {
 96          match component {
 97              std::path::Component::ParentDir => {
 98                  if !components.is_empty() {
 99                      components.pop();
100                  }
101              }
102              std::path::Component::CurDir => {}
103              _ => {
104                  components.push(component);
105              }
106          }
107      }
108  
109      components.iter().collect()
110  }
111  
112  #[cfg(test)]
113  mod tests {
114      use super::*;
115      use std::fs::{self, File};
116      use tempfile::TempDir;
117  
118      struct MockLanguage {
119          extensions: &'static [&'static str],
120      }
121  
122      impl LanguageSupport for MockLanguage {
123          fn id(&self) -> &'static str {
124              "mock"
125          }
126  
127          fn extensions(&self) -> &'static [&'static str] {
128              self.extensions
129          }
130  
131          fn language_ids(&self) -> &'static [&'static str] {
132              &["mock"]
133          }
134  
135          fn grammar(&self) -> tree_sitter::Language {
136              tree_sitter_javascript::LANGUAGE.into()
137          }
138  
139          fn reference_query(&self) -> &tree_sitter::Query {
140              static QUERY: std::sync::OnceLock<tree_sitter::Query> = std::sync::OnceLock::new();
141              QUERY.get_or_init(|| {
142                  tree_sitter::Query::new(
143                      &tree_sitter_javascript::LANGUAGE.into(),
144                      "(identifier) @id",
145                  )
146                  .unwrap()
147              })
148          }
149      }
150  
151      fn setup_test_workspace() -> (TempDir, PathBuf) {
152          let temp_dir = TempDir::new().unwrap();
153          let workspace = temp_dir.path().to_path_buf();
154  
155          fs::create_dir_all(workspace.join("src/utils")).unwrap();
156          fs::create_dir_all(workspace.join("src/config")).unwrap();
157  
158          File::create(workspace.join("src/config.ts")).unwrap();
159          File::create(workspace.join("src/utils/env.ts")).unwrap();
160          File::create(workspace.join("src/config/index.ts")).unwrap();
161          File::create(workspace.join("src/utils/helpers.js")).unwrap();
162  
163          (temp_dir, workspace)
164      }
165  
166      #[test]
167      fn test_resolve_relative_import() {
168          let (_temp, workspace) = setup_test_workspace();
169          let resolver = ModuleResolver::new(workspace.clone());
170          let lang = MockLanguage {
171              extensions: &["ts", "tsx", "js", "jsx"],
172          };
173  
174          let from_uri = Url::from_file_path(workspace.join("src/index.ts")).unwrap();
175  
176          let result = resolver.resolve("./config", &from_uri, &lang);
177          assert_eq!(result, Some(workspace.join("src/config.ts")));
178  
179          let result = resolver.resolve("./utils/env", &from_uri, &lang);
180          assert_eq!(result, Some(workspace.join("src/utils/env.ts")));
181      }
182  
183      #[test]
184      fn test_resolve_parent_directory() {
185          let (_temp, workspace) = setup_test_workspace();
186          let resolver = ModuleResolver::new(workspace.clone());
187          let lang = MockLanguage {
188              extensions: &["ts", "tsx", "js", "jsx"],
189          };
190  
191          let from_uri = Url::from_file_path(workspace.join("src/utils/helpers.js")).unwrap();
192  
193          let result = resolver.resolve("../config", &from_uri, &lang);
194          assert_eq!(result, Some(workspace.join("src/config.ts")));
195      }
196  
197      #[test]
198      fn test_resolve_index_file() {
199          let (_temp, workspace) = setup_test_workspace();
200          let resolver = ModuleResolver::new(workspace.clone());
201          let lang = MockLanguage {
202              extensions: &["ts", "tsx", "js", "jsx"],
203          };
204  
205          let from_uri = Url::from_file_path(workspace.join("src/index.ts")).unwrap();
206  
207          let result = resolver.resolve("./config", &from_uri, &lang);
208          assert_eq!(result, Some(workspace.join("src/config.ts")));
209      }
210  
211      #[test]
212      fn test_no_resolve_package_import() {
213          let (_temp, workspace) = setup_test_workspace();
214          let resolver = ModuleResolver::new(workspace.clone());
215          let lang = MockLanguage {
216              extensions: &["ts", "tsx", "js", "jsx"],
217          };
218  
219          let from_uri = Url::from_file_path(workspace.join("src/index.ts")).unwrap();
220  
221          assert!(resolver.resolve("lodash", &from_uri, &lang).is_none());
222          assert!(resolver.resolve("@scope/pkg", &from_uri, &lang).is_none());
223          assert!(resolver.resolve("react", &from_uri, &lang).is_none());
224      }
225  
226      #[test]
227      fn test_no_resolve_absolute_import() {
228          let (_temp, workspace) = setup_test_workspace();
229          let resolver = ModuleResolver::new(workspace.clone());
230          let lang = MockLanguage {
231              extensions: &["ts", "tsx", "js", "jsx"],
232          };
233  
234          let from_uri = Url::from_file_path(workspace.join("src/index.ts")).unwrap();
235  
236          assert!(resolver
237              .resolve("/absolute/path", &from_uri, &lang)
238              .is_none());
239      }
240  
241      #[test]
242      fn test_no_resolve_outside_workspace() {
243          let (_temp, workspace) = setup_test_workspace();
244          let resolver = ModuleResolver::new(workspace.clone());
245          let lang = MockLanguage {
246              extensions: &["ts", "tsx", "js", "jsx"],
247          };
248  
249          let from_uri = Url::from_file_path(workspace.join("src/index.ts")).unwrap();
250  
251          assert!(resolver
252              .resolve("../../outside/workspace", &from_uri, &lang)
253              .is_none());
254      }
255  
256      #[test]
257      fn test_is_relative_import() {
258          assert!(ModuleResolver::is_relative_import("./config"));
259          assert!(ModuleResolver::is_relative_import("../utils"));
260          assert!(!ModuleResolver::is_relative_import("lodash"));
261          assert!(!ModuleResolver::is_relative_import("@scope/pkg"));
262          assert!(!ModuleResolver::is_relative_import("/absolute"));
263      }
264  
265      #[test]
266      fn test_is_package_import() {
267          assert!(ModuleResolver::is_package_import("lodash"));
268          assert!(ModuleResolver::is_package_import("@scope/pkg"));
269          assert!(ModuleResolver::is_package_import("react"));
270          assert!(!ModuleResolver::is_package_import("./config"));
271          assert!(!ModuleResolver::is_package_import("../utils"));
272          assert!(!ModuleResolver::is_package_import("/absolute"));
273      }
274  
275      #[test]
276      fn test_resolve_to_uri() {
277          let (_temp, workspace) = setup_test_workspace();
278          let resolver = ModuleResolver::new(workspace.clone());
279          let lang = MockLanguage {
280              extensions: &["ts", "tsx", "js", "jsx"],
281          };
282  
283          let from_uri = Url::from_file_path(workspace.join("src/index.ts")).unwrap();
284  
285          let result = resolver.resolve_to_uri("./config", &from_uri, &lang);
286          assert!(result.is_some());
287          let uri = result.unwrap();
288          assert!(uri.path().ends_with("config.ts"));
289      }
290  
291      #[test]
292      fn test_normalize_path() {
293          let path = Path::new("/workspace/src/../src/./config");
294          let normalized = normalize_path(path);
295          assert_eq!(normalized, PathBuf::from("/workspace/src/config"));
296      }
297  }