|
| 1 | +use std::io; |
1 | 2 | use std::path::Path; |
2 | 3 | use std::sync::Arc; |
3 | 4 |
|
| 5 | +use crate::error::{Error, RichError, WithSpan as _}; |
| 6 | +use crate::parse::UseDecl; |
| 7 | + |
4 | 8 | /// Powers error reporting by mapping compiler diagnostics to the specific file. |
5 | 9 | #[derive(Debug, Clone, Eq, PartialEq, Hash)] |
6 | 10 | pub struct SourceFile { |
@@ -44,3 +48,285 @@ impl SourceFile { |
44 | 48 | self.content.clone() |
45 | 49 | } |
46 | 50 | } |
| 51 | + |
| 52 | +/// A guaranteed, fully coanonicalized absolute path. |
| 53 | +#[derive(Debug, Clone, Eq, PartialEq, Hash)] |
| 54 | +pub struct CanonPath(Arc<Path>); |
| 55 | + |
| 56 | +impl CanonPath { |
| 57 | + /// Safely resolves an absolute path via the OS and wraps it in a `CanonPath`. |
| 58 | + /// |
| 59 | + /// # Errors |
| 60 | + /// |
| 61 | + /// Returns a `String` containing the OS error if the path does not exist or |
| 62 | + /// cannot be accessed. The caller is expected to map this into a more specific |
| 63 | + /// compiler diagnostic (e.g., `RichError`). |
| 64 | + pub fn canonicalize(path: &Path) -> Result<Self, String> { |
| 65 | + // We use `map_err` here to intercept the generic OS error and enrich |
| 66 | + // it with the specific path that failed |
| 67 | + let canon_path = std::fs::canonicalize(path).map_err(|err| { |
| 68 | + format!( |
| 69 | + "Failed to find library target path '{}' :{}", |
| 70 | + path.display(), |
| 71 | + err |
| 72 | + ) |
| 73 | + })?; |
| 74 | + |
| 75 | + Ok(Self(Arc::from(canon_path.as_path()))) |
| 76 | + } |
| 77 | + |
| 78 | + /// Appends a logical module path to this physical root directory and verifies it. |
| 79 | + /// It automatically appends the `.simf` extension to the final path *before* asking |
| 80 | + /// the OS to verify its existence. |
| 81 | + pub fn join(&self, parts: &[&str]) -> Result<Self, String> { |
| 82 | + let mut new_path = self.0.to_path_buf(); |
| 83 | + |
| 84 | + for part in parts { |
| 85 | + new_path.push(part); |
| 86 | + } |
| 87 | + |
| 88 | + Self::canonicalize(&new_path.with_extension("simf")) |
| 89 | + } |
| 90 | + |
| 91 | + /// Check if the current file is executing inside the context's directory tree. |
| 92 | + /// This prevents a file in `/project_a/` from using a dependency meant for `/project_b/` |
| 93 | + pub fn starts_with(&self, path: &CanonPath) -> bool { |
| 94 | + self.as_path().starts_with(path.as_path()) |
| 95 | + } |
| 96 | + |
| 97 | + pub fn as_path(&self) -> &Path { |
| 98 | + &self.0 |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +/// This defines how a specific dependency root path (e.g. "math") |
| 103 | +/// should be resolved to a physical path on the disk, restricted to |
| 104 | +/// files executing within the `context_prefix`. |
| 105 | +#[derive(Debug, Clone)] |
| 106 | +pub struct Remapping { |
| 107 | + /// The base directory that owns this dependency mapping. |
| 108 | + pub context_prefix: CanonPath, |
| 109 | + /// The dependency root path name used in the `use` statement (e.g., "math"). |
| 110 | + pub drp_name: String, |
| 111 | + /// The physical path this dependency root path points to. |
| 112 | + pub target: CanonPath, |
| 113 | +} |
| 114 | + |
| 115 | +/// A router for resolving dependencies across multi-file workspaces. |
| 116 | +/// |
| 117 | +/// Mappings are strictly sorted by the longest `context_prefix` match. |
| 118 | +/// This mathematical guarantee ensures that if multiple nested directories |
| 119 | +/// define the same dependency root path, the most specific (deepest) context wins. |
| 120 | +#[derive(Debug, Default)] |
| 121 | +pub struct DependencyMap { |
| 122 | + inner: Vec<Remapping>, |
| 123 | +} |
| 124 | + |
| 125 | +impl DependencyMap { |
| 126 | + pub fn new() -> Self { |
| 127 | + Self::default() |
| 128 | + } |
| 129 | + |
| 130 | + pub fn is_empty(&self) -> bool { |
| 131 | + self.inner.is_empty() |
| 132 | + } |
| 133 | + |
| 134 | + /// Re-sort the vector in descending order so the longest context paths are always at the front. |
| 135 | + /// This mathematically guarantees that the first match we find is the most specific. |
| 136 | + fn sort_mappings(&mut self) { |
| 137 | + self.inner.sort_by(|a, b| { |
| 138 | + let len_a = a.context_prefix.as_path().as_os_str().len(); |
| 139 | + let len_b = b.context_prefix.as_path().as_os_str().len(); |
| 140 | + len_b.cmp(&len_a) |
| 141 | + }); |
| 142 | + } |
| 143 | + |
| 144 | + /// Add a dependency mapped to a specific calling file's path prefix. |
| 145 | + /// Re-sorts the vector internally to guarantee the Longest Prefix Match. |
| 146 | + /// |
| 147 | + /// # Arguments |
| 148 | + /// |
| 149 | + /// * `context` - The physical root directory where this dependency rule applies |
| 150 | + /// (e.g., `/workspace/frontend`). |
| 151 | + /// * `drp_name` - The Dependency Root Path Name. This is the logical alias the |
| 152 | + /// programmer types in their source code (e.g., the `"math"` in `use math::vector;`). |
| 153 | + /// * `target` - The physical directory where the compiler should actually |
| 154 | + /// look for the code (e.g., `/libs/frontend_math`). |
| 155 | + pub fn insert( |
| 156 | + &mut self, |
| 157 | + context: CanonPath, |
| 158 | + drp_name: String, |
| 159 | + target: CanonPath, |
| 160 | + ) -> io::Result<()> { |
| 161 | + self.inner.push(Remapping { |
| 162 | + context_prefix: context, |
| 163 | + drp_name, |
| 164 | + target, |
| 165 | + }); |
| 166 | + |
| 167 | + self.sort_mappings(); |
| 168 | + |
| 169 | + Ok(()) |
| 170 | + } |
| 171 | + |
| 172 | + /// Resolve `use dependency_root_path_name::...` into a physical file path by finding the |
| 173 | + /// most specific library context that owns the current file. |
| 174 | + pub fn resolve_path( |
| 175 | + &self, |
| 176 | + current_file: CanonPath, |
| 177 | + use_decl: &UseDecl, |
| 178 | + ) -> Result<CanonPath, RichError> { |
| 179 | + let parts = use_decl.path(); |
| 180 | + let drp_name = use_decl.drp_name()?; |
| 181 | + |
| 182 | + // Because the vector is sorted by longest prefix, |
| 183 | + // the VERY FIRST match we find is guaranteed to be the correct one. |
| 184 | + for remapping in &self.inner { |
| 185 | + if !current_file.starts_with(&remapping.context_prefix) { |
| 186 | + continue; |
| 187 | + } |
| 188 | + |
| 189 | + // Check if the alias matches what the user typed |
| 190 | + if remapping.drp_name == drp_name { |
| 191 | + return remapping.target.join(&parts[1..]).map_err(|err| { |
| 192 | + RichError::new( |
| 193 | + Error::Internal(format!("Dependency resolution failed: {}", err)), |
| 194 | + *use_decl.span(), |
| 195 | + ) |
| 196 | + }); |
| 197 | + } |
| 198 | + } |
| 199 | + |
| 200 | + Err(Error::UnknownLibrary(drp_name.to_string())).with_span(*use_decl.span()) |
| 201 | + } |
| 202 | +} |
| 203 | + |
| 204 | +#[cfg(test)] |
| 205 | +mod tests { |
| 206 | + use crate::str::Identifier; |
| 207 | + use crate::test_utils::TempWorkspace; |
| 208 | + |
| 209 | + use super::*; |
| 210 | + |
| 211 | + /// Helper to easily construct a `UseDecl` for path resolution tests. |
| 212 | + fn create_dummy_use_decl(path_segments: &[&str]) -> UseDecl { |
| 213 | + let path: Vec<Identifier> = path_segments |
| 214 | + .iter() |
| 215 | + .map(|&s| Identifier::dummy(s)) |
| 216 | + .collect(); |
| 217 | + |
| 218 | + UseDecl::dummy_path(path) |
| 219 | + } |
| 220 | + |
| 221 | + fn canon(p: &Path) -> CanonPath { |
| 222 | + CanonPath::canonicalize(p).unwrap() |
| 223 | + } |
| 224 | + |
| 225 | + /// When a user registers the same library dependency root path multiple times |
| 226 | + /// for different folders, the compiler must always check the longest folder path first. |
| 227 | + #[test] |
| 228 | + fn test_sorting_longest_prefix() { |
| 229 | + let ws = TempWorkspace::new("sorting"); |
| 230 | + |
| 231 | + let workspace_dir = canon(&ws.create_dir("workspace")); |
| 232 | + let nested_dir = canon(&ws.create_dir("workspace/project_a/nested")); |
| 233 | + let project_a_dir = canon(&ws.create_dir("workspace/project_a")); |
| 234 | + |
| 235 | + let target_v1 = canon(&ws.create_dir("lib/math_v1")); |
| 236 | + let target_v3 = canon(&ws.create_dir("lib/math_v3")); |
| 237 | + let target_v2 = canon(&ws.create_dir("lib/math_v2")); |
| 238 | + |
| 239 | + let mut map = DependencyMap::new(); |
| 240 | + map.insert(workspace_dir.clone(), "math".to_string(), target_v1) |
| 241 | + .unwrap(); |
| 242 | + map.insert(nested_dir.clone(), "math".to_string(), target_v3) |
| 243 | + .unwrap(); |
| 244 | + map.insert(project_a_dir.clone(), "math".to_string(), target_v2) |
| 245 | + .unwrap(); |
| 246 | + |
| 247 | + // The longest prefixes should bubble to the top |
| 248 | + assert_eq!(map.inner[0].context_prefix, nested_dir); |
| 249 | + assert_eq!(map.inner[1].context_prefix, project_a_dir); |
| 250 | + assert_eq!(map.inner[2].context_prefix, workspace_dir); |
| 251 | + } |
| 252 | + |
| 253 | + /// Projects should not be able to "steal" or accidentally access dependencies |
| 254 | + /// that do not belong to them. |
| 255 | + #[test] |
| 256 | + fn test_context_isolation() { |
| 257 | + let ws = TempWorkspace::new("isolation"); |
| 258 | + |
| 259 | + let project_a = canon(&ws.create_dir("project_a")); |
| 260 | + let target_utils = canon(&ws.create_dir("libs/utils_a")); |
| 261 | + let current_file = canon(&ws.create_file("project_b/main.simf", "")); |
| 262 | + |
| 263 | + let mut map = DependencyMap::new(); |
| 264 | + map.insert(project_a, "utils".to_string(), target_utils) |
| 265 | + .unwrap(); |
| 266 | + |
| 267 | + let use_decl = create_dummy_use_decl(&["utils"]); |
| 268 | + let result = map.resolve_path(current_file, &use_decl); |
| 269 | + |
| 270 | + assert!(result.is_err()); |
| 271 | + assert!(matches!( |
| 272 | + result.unwrap_err().error(), |
| 273 | + Error::UnknownLibrary(..) |
| 274 | + )); |
| 275 | + } |
| 276 | + |
| 277 | + /// It proves that a highly specific path definition will "override" or "shadow" |
| 278 | + /// a broader path definition. |
| 279 | + #[test] |
| 280 | + fn test_resolve_longest_prefix_match() { |
| 281 | + let ws = TempWorkspace::new("resolve_prefix"); |
| 282 | + |
| 283 | + // 1. Setup Global Context |
| 284 | + let global_context = canon(&ws.create_dir("workspace")); |
| 285 | + let global_target = canon(&ws.create_dir("libs/global_math")); |
| 286 | + let global_expected = canon(&ws.create_file("libs/global_math/vector.simf", "")); |
| 287 | + |
| 288 | + // 2. Setup Frontend Context |
| 289 | + let frontend_context = canon(&ws.create_dir("workspace/frontend")); |
| 290 | + let frontend_target = canon(&ws.create_dir("libs/frontend_math")); |
| 291 | + let frontend_expected = canon(&ws.create_file("libs/frontend_math/vector.simf", "")); |
| 292 | + |
| 293 | + let mut map = DependencyMap::new(); |
| 294 | + map.insert(global_context, "math".to_string(), global_target) |
| 295 | + .unwrap(); |
| 296 | + map.insert(frontend_context, "math".to_string(), frontend_target) |
| 297 | + .unwrap(); |
| 298 | + |
| 299 | + let use_decl = create_dummy_use_decl(&["math", "vector"]); |
| 300 | + |
| 301 | + // 3. Test Frontend Override |
| 302 | + let frontend_file = canon(&ws.create_file("workspace/frontend/src/main.simf", "")); |
| 303 | + let resolved_frontend = map.resolve_path(frontend_file, &use_decl).unwrap(); |
| 304 | + assert_eq!(resolved_frontend, frontend_expected); |
| 305 | + |
| 306 | + // 4. Test Global Fallback |
| 307 | + let backend_file = canon(&ws.create_file("workspace/backend/src/main.simf", "")); |
| 308 | + let resolved_backend = map.resolve_path(backend_file, &use_decl).unwrap(); |
| 309 | + assert_eq!(resolved_backend, global_expected); |
| 310 | + } |
| 311 | + |
| 312 | + /// it proves that `start_with()` and `resolve_path()` logic correctly handles files |
| 313 | + /// that are buried deep inside a project's subdirectories. |
| 314 | + #[test] |
| 315 | + fn test_resolve_relative_current_file_against_canonical_context() { |
| 316 | + let ws = TempWorkspace::new("relative_current"); |
| 317 | + |
| 318 | + let context = canon(&ws.create_dir("workspace/frontend")); |
| 319 | + let target = canon(&ws.create_dir("libs/frontend_math")); |
| 320 | + let expected = canon(&ws.create_file("libs/frontend_math/vector.simf", "")); |
| 321 | + |
| 322 | + let current_file = canon(&ws.create_file("workspace/frontend/src/main.simf", "")); |
| 323 | + |
| 324 | + let mut map = DependencyMap::new(); |
| 325 | + map.insert(context, "math".to_string(), target).unwrap(); |
| 326 | + |
| 327 | + let use_decl = create_dummy_use_decl(&["math", "vector"]); |
| 328 | + let result = map.resolve_path(current_file, &use_decl).unwrap(); |
| 329 | + |
| 330 | + assert_eq!(result, expected); |
| 331 | + } |
| 332 | +} |
0 commit comments