Skip to content

Commit f609987

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

5 files changed

Lines changed: 389 additions & 6 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: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ pub enum Visibility {
7272
///
7373
/// This structure defines how items from other modules or files are brought into the
7474
/// current scope. Note that in this architecture, the first identifier in the path
75-
/// is always treated as an alias bound to a specific physical path.
75+
/// is always treated as an dependency root path name bound to a specific physical path.
7676
///
7777
/// # Example
7878
/// ```text
@@ -86,8 +86,8 @@ pub struct UseDecl {
8686

8787
/// The base path to the target file or module.
8888
///
89-
/// The first element is always the registered alias for the import path.
90-
/// Subsequent elements represent nested modules or directories.
89+
/// The first element is always the registered dependency root path name for
90+
/// the import path. Subsequent elements represent nested modules or directories.
9191
path: Vec<Identifier>,
9292

9393
/// The specific item or list of items being imported from the resolved path.
@@ -100,8 +100,25 @@ impl UseDecl {
100100
&self.visibility
101101
}
102102

103-
pub fn path(&self) -> &Vec<Identifier> {
104-
&self.path
103+
/// Returns the full logical module path as a vector of string slices.
104+
///
105+
/// This includes the Dependency Root Path Name (the first segment)
106+
/// followed by all subsequent sub-module segments.
107+
pub fn path(&self) -> Vec<&str> {
108+
self.path.iter().map(|s| s.as_inner()).collect()
109+
}
110+
111+
/// Extracts the Dependency Root Path Name (the very first segment) from this path.
112+
///
113+
/// # Errors
114+
///
115+
/// Returns a `RichError` if the use declaration path is completely empty.
116+
pub fn drp_name(&self) -> Result<&str, RichError> {
117+
let parts = self.path();
118+
parts
119+
.first()
120+
.copied()
121+
.ok_or_else(|| Error::CannotParse("Empty use path".to_string()).with_span(self.span))
105122
}
106123

107124
pub fn items(&self) -> &UseItems {
@@ -113,7 +130,7 @@ impl UseDecl {
113130
}
114131
}
115132

116-
impl_eq_hash!(UseDecl; visibility, path, items);
133+
impl_eq_hash!(UseDecl; visibility, path, drp_name, items);
117134

118135
/// Specified the items being brought into scope at the end of a `use` declaration
119136
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -2385,6 +2402,18 @@ impl crate::ArbitraryRec for Match {
23852402
mod test {
23862403
use super::*;
23872404

2405+
impl UseDecl {
2406+
/// Creates a dummy `UseDecl` specifically for testing `DependencyMap` resolution.
2407+
pub fn dummy_path(path: Vec<Identifier>) -> Self {
2408+
Self {
2409+
visibility: Visibility::default(),
2410+
path,
2411+
items: UseItems::List(Vec::new()),
2412+
span: Span::new(0, 0),
2413+
}
2414+
}
2415+
}
2416+
23882417
#[test]
23892418
fn test_reject_redefined_builtin_type() {
23902419
let ty = TypeAlias::parse_from_str("type Ctx8 = u32")

src/resolution.rs

Lines changed: 286 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,285 @@ 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+
/// 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+
}

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+
}

0 commit comments

Comments
 (0)