Skip to content

Commit 683082b

Browse files
committed
feat: add data structure for dependency resolution
1 parent 1e16a74 commit 683082b

5 files changed

Lines changed: 361 additions & 0 deletions

File tree

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ pub mod resolution;
1818
#[cfg(feature = "serde")]
1919
mod serde;
2020
pub mod str;
21+
#[cfg(test)]
22+
pub mod test_utils;
2123
pub mod tracker;
2224
pub mod types;
2325
pub mod value;

src/parse.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2385,6 +2385,19 @@ impl crate::ArbitraryRec for Match {
23852385
mod test {
23862386
use super::*;
23872387

2388+
impl UseDecl {
2389+
/// Creates a dummy `UseDecl` specifically for testing `DependencyMap` resolution.
2390+
/// It automatically builds the internal Identifier path.
2391+
pub fn dummy_path(path: Vec<Identifier>) -> Self {
2392+
Self {
2393+
visibility: Visibility::default(),
2394+
path,
2395+
items: UseItems::List(Vec::new()),
2396+
span: Span::new(0, 0),
2397+
}
2398+
}
2399+
}
2400+
23882401
#[test]
23892402
fn test_reject_redefined_builtin_type() {
23902403
let ty = TypeAlias::parse_from_str("type Ctx8 = u32")

src/resolution.rs

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
use std::io;
12
use std::path::Path;
23
use std::sync::Arc;
34

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

src/str.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,14 @@ impl ModuleName {
293293
}
294294

295295
wrapped_string!(ModuleName, "module name");
296+
297+
#[cfg(test)]
298+
mod tests {
299+
use super::*;
300+
301+
impl Identifier {
302+
pub fn dummy(name: &str) -> Self {
303+
Self(std::sync::Arc::from(name))
304+
}
305+
}
306+
}

src/test_utils.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
use std::fs;
2+
use std::path::PathBuf;
3+
use std::time;
4+
5+
/// A self-cleaning temporary workspace for unit tests.
6+
/// Completely replaces the need for the external `tempfile` crate.
7+
pub struct TempWorkspace {
8+
root: PathBuf,
9+
}
10+
11+
impl TempWorkspace {
12+
/// Generates a mathematically unique temporary directory on the OS
13+
/// and physically creates it.
14+
pub fn new(test_name: &str) -> Self {
15+
let unique = time::SystemTime::now()
16+
.duration_since(time::UNIX_EPOCH)
17+
.unwrap()
18+
.as_nanos();
19+
let cwd = std::env::current_dir().unwrap();
20+
let root = cwd.join("target").join(format!(
21+
"test-{}-{}-{}",
22+
test_name,
23+
std::process::id(),
24+
unique
25+
));
26+
fs::create_dir_all(&root).unwrap();
27+
Self { root }
28+
}
29+
30+
/// Helper to physically create a nested directory inside the workspace.
31+
pub fn create_dir(&self, rel_path: &str) -> PathBuf {
32+
let path = self.root.join(rel_path);
33+
fs::create_dir_all(&path).unwrap();
34+
path
35+
}
36+
37+
/// Helper to physically create a file (and any necessary parent directories)
38+
/// with string contents.
39+
pub fn create_file(&self, rel_path: &str, contents: &str) -> PathBuf {
40+
let path = self.root.join(rel_path);
41+
42+
if let Some(parent) = path.parent() {
43+
fs::create_dir_all(parent).unwrap();
44+
}
45+
46+
fs::write(&path, contents).unwrap();
47+
path
48+
}
49+
}
50+
51+
impl Drop for TempWorkspace {
52+
fn drop(&mut self) {
53+
let _ = fs::remove_dir_all(&self.root);
54+
}
55+
}

0 commit comments

Comments
 (0)