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 }