Skip to main content

gruel_air/sema/
module_path.rs

1//! Structured import path resolution.
2//!
3//! This module provides a structured approach to resolving import paths in Gruel.
4//! Instead of ad-hoc string matching with many special cases, it uses a typed
5//! representation of different import path kinds and explicit resolution order.
6//!
7//! # Resolution Order
8//!
9//! When resolving an import path like `@import("foo")`, we check in this order:
10//!
11//! 1. **Standard library** - if the path is exactly "std"
12//! 2. **Exact path with extension** - if path includes ".gruel" extension
13//! 3. **Simple file match** - look for `foo.gruel` or path ending with `foo.gruel`
14//! 4. **Facade module** - look for `_foo.gruel` (directory module entry point)
15//!
16//! The first match wins, so `foo.gruel` takes precedence over `_foo.gruel`.
17
18use std::path::Path;
19
20/// Represents a parsed import path with its resolution strategy.
21///
22/// This enum categorizes import paths to determine how they should be resolved.
23/// Each variant corresponds to a different resolution strategy.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum ModulePath {
26    /// Standard library import: `@import("std")`
27    ///
28    /// This is a special case that is currently not supported during const eval.
29    Std,
30
31    /// Import with explicit `.gruel` extension: `@import("foo.gruel")`
32    ///
33    /// The path is taken as-is and matched against loaded file paths.
34    ExplicitRue { path: String },
35
36    /// Simple module import: `@import("foo")` or `@import("utils/strings")`
37    ///
38    /// Resolution tries:
39    /// 1. `{path}.gruel` - standard file
40    /// 2. `_{basename}.gruel` - facade file for directory modules
41    ///
42    /// For nested paths like `utils/strings`, we look for `utils/strings.gruel`.
43    Simple { path: String },
44}
45
46impl ModulePath {
47    /// Parse an import path string into a structured `ModulePath`.
48    ///
49    /// This determines the kind of import based on the path format.
50    ///
51    /// # Examples
52    ///
53    /// ```ignore
54    /// ModulePath::parse("std") => ModulePath::Std
55    /// ModulePath::parse("foo.gruel") => ModulePath::ExplicitRue { path: "foo.gruel" }
56    /// ModulePath::parse("foo") => ModulePath::Simple { path: "foo" }
57    /// ModulePath::parse("utils/strings") => ModulePath::Simple { path: "utils/strings" }
58    /// ```
59    pub fn parse(import_path: &str) -> Self {
60        // Check for standard library
61        if import_path == "std" {
62            return ModulePath::Std;
63        }
64
65        // Check for explicit .gruel extension
66        if import_path.ends_with(".gruel") {
67            return ModulePath::ExplicitRue {
68                path: import_path.to_string(),
69            };
70        }
71
72        // Otherwise, it's a simple module import
73        ModulePath::Simple {
74            path: import_path.to_string(),
75        }
76    }
77
78    /// Resolve this import path against a collection of loaded file paths.
79    ///
80    /// Returns `Some(resolved_path)` if a match is found, or `None` if the
81    /// module cannot be found.
82    ///
83    /// The resolution order is:
84    /// 1. Exact match (for ExplicitRue)
85    /// 2. Standard file match (`{path}.gruel`)
86    /// 3. Path suffix match (for nested paths)
87    /// 4. Facade file match (`_{basename}.gruel`)
88    pub fn resolve<'a, I>(&self, loaded_paths: I) -> Option<String>
89    where
90        I: Iterator<Item = &'a String>,
91    {
92        match self {
93            ModulePath::Std => {
94                // Standard library not supported yet
95                None
96            }
97            ModulePath::ExplicitRue { path } => self.resolve_explicit(path, loaded_paths),
98            ModulePath::Simple { path } => self.resolve_simple(path, loaded_paths),
99        }
100    }
101
102    /// Resolve an explicit `.gruel` path.
103    fn resolve_explicit<'a, I>(&self, import_path: &str, loaded_paths: I) -> Option<String>
104    where
105        I: Iterator<Item = &'a String>,
106    {
107        let collected: Vec<_> = loaded_paths.collect();
108
109        // Priority 1: Exact match
110        for file_path in &collected {
111            if *file_path == import_path {
112                return Some((*file_path).clone());
113            }
114        }
115
116        // Priority 2: Path ends with import_path
117        // This handles cases like "foo.gruel" matching "/path/to/foo.gruel"
118        for file_path in &collected {
119            if file_path.ends_with(import_path) {
120                // Verify it's a proper path boundary (preceded by / or start of string)
121                let prefix_len = file_path.len() - import_path.len();
122                if prefix_len == 0 || file_path.as_bytes()[prefix_len - 1] == b'/' {
123                    return Some((*file_path).clone());
124                }
125            }
126        }
127
128        None
129    }
130
131    /// Resolve a simple (no extension) import path.
132    fn resolve_simple<'a, I>(&self, import_path: &str, loaded_paths: I) -> Option<String>
133    where
134        I: Iterator<Item = &'a String>,
135    {
136        let import_with_gruel = format!("{}.gruel", import_path);
137        let collected: Vec<_> = loaded_paths.collect();
138
139        // Extract the basename for facade file matching
140        let basename = Path::new(import_path)
141            .file_name()
142            .and_then(|s| s.to_str())
143            .unwrap_or(import_path);
144        let facade_name = format!("_{}.gruel", basename);
145
146        // Priority 1: Look for exact {path}.gruel
147        for file_path in &collected {
148            if *file_path == &import_with_gruel {
149                return Some((*file_path).clone());
150            }
151        }
152
153        // Priority 2: Look for path ending with {path}.gruel
154        // This handles "utils/strings" matching "/project/utils/strings.gruel"
155        for file_path in &collected {
156            if file_path.ends_with(&import_with_gruel) {
157                // Verify it's a proper path boundary
158                let prefix_len = file_path.len() - import_with_gruel.len();
159                if prefix_len == 0 || file_path.as_bytes()[prefix_len - 1] == b'/' {
160                    return Some((*file_path).clone());
161                }
162            }
163        }
164
165        // Priority 3: Look for files matching just the basename (e.g., "math" matches "src/math.gruel")
166        for file_path in &collected {
167            if let Some(file_name) = Path::new(file_path.as_str())
168                .file_stem()
169                .and_then(|s| s.to_str())
170                && file_name == basename
171            {
172                return Some((*file_path).clone());
173            }
174        }
175
176        // Priority 4: Look for facade file (_foo.gruel)
177        for file_path in &collected {
178            if let Some(file_name) = Path::new(file_path.as_str())
179                .file_name()
180                .and_then(|s| s.to_str())
181                && file_name == facade_name
182            {
183                return Some((*file_path).clone());
184            }
185        }
186
187        None
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    // =========================================================================
196    // Parsing tests
197    // =========================================================================
198
199    #[test]
200    fn test_parse_std() {
201        assert_eq!(ModulePath::parse("std"), ModulePath::Std);
202    }
203
204    #[test]
205    fn test_parse_explicit_gruel() {
206        assert_eq!(
207            ModulePath::parse("foo.gruel"),
208            ModulePath::ExplicitRue {
209                path: "foo.gruel".to_string()
210            }
211        );
212        assert_eq!(
213            ModulePath::parse("utils/strings.gruel"),
214            ModulePath::ExplicitRue {
215                path: "utils/strings.gruel".to_string()
216            }
217        );
218    }
219
220    #[test]
221    fn test_parse_simple() {
222        assert_eq!(
223            ModulePath::parse("foo"),
224            ModulePath::Simple {
225                path: "foo".to_string()
226            }
227        );
228        assert_eq!(
229            ModulePath::parse("utils/strings"),
230            ModulePath::Simple {
231                path: "utils/strings".to_string()
232            }
233        );
234    }
235
236    // =========================================================================
237    // Resolution tests - Standard library
238    // =========================================================================
239
240    #[test]
241    fn test_resolve_std_not_supported() {
242        let paths = ["main.gruel".to_string()];
243        let module = ModulePath::Std;
244        assert_eq!(module.resolve(paths.iter()), None);
245    }
246
247    // =========================================================================
248    // Resolution tests - Explicit .gruel extension
249    // =========================================================================
250
251    #[test]
252    fn test_resolve_explicit_exact_match() {
253        let paths = ["foo.gruel".to_string(), "bar.gruel".to_string()];
254        let module = ModulePath::ExplicitRue {
255            path: "foo.gruel".to_string(),
256        };
257        assert_eq!(module.resolve(paths.iter()), Some("foo.gruel".to_string()));
258    }
259
260    #[test]
261    fn test_resolve_explicit_suffix_match() {
262        let paths = ["/project/src/foo.gruel".to_string()];
263        let module = ModulePath::ExplicitRue {
264            path: "foo.gruel".to_string(),
265        };
266        assert_eq!(
267            module.resolve(paths.iter()),
268            Some("/project/src/foo.gruel".to_string())
269        );
270    }
271
272    #[test]
273    fn test_resolve_explicit_no_false_substring_match() {
274        // "foo.gruel" should NOT match "xfoo.gruel" (no path boundary)
275        let paths = ["xfoo.gruel".to_string()];
276        let module = ModulePath::ExplicitRue {
277            path: "foo.gruel".to_string(),
278        };
279        assert_eq!(module.resolve(paths.iter()), None);
280    }
281
282    #[test]
283    fn test_resolve_explicit_nested_path() {
284        let paths = ["/project/utils/strings.gruel".to_string()];
285        let module = ModulePath::ExplicitRue {
286            path: "utils/strings.gruel".to_string(),
287        };
288        assert_eq!(
289            module.resolve(paths.iter()),
290            Some("/project/utils/strings.gruel".to_string())
291        );
292    }
293
294    // =========================================================================
295    // Resolution tests - Simple (no extension)
296    // =========================================================================
297
298    #[test]
299    fn test_resolve_simple_exact_match() {
300        let paths = ["foo.gruel".to_string()];
301        let module = ModulePath::Simple {
302            path: "foo".to_string(),
303        };
304        assert_eq!(module.resolve(paths.iter()), Some("foo.gruel".to_string()));
305    }
306
307    #[test]
308    fn test_resolve_simple_suffix_match() {
309        let paths = ["/project/src/foo.gruel".to_string()];
310        let module = ModulePath::Simple {
311            path: "foo".to_string(),
312        };
313        assert_eq!(
314            module.resolve(paths.iter()),
315            Some("/project/src/foo.gruel".to_string())
316        );
317    }
318
319    #[test]
320    fn test_resolve_simple_nested_path() {
321        let paths = ["/project/utils/strings.gruel".to_string()];
322        let module = ModulePath::Simple {
323            path: "utils/strings".to_string(),
324        };
325        assert_eq!(
326            module.resolve(paths.iter()),
327            Some("/project/utils/strings.gruel".to_string())
328        );
329    }
330
331    #[test]
332    fn test_resolve_simple_basename_match() {
333        // "math" should match "src/math.gruel" by basename
334        let paths = ["src/math.gruel".to_string()];
335        let module = ModulePath::Simple {
336            path: "math".to_string(),
337        };
338        assert_eq!(
339            module.resolve(paths.iter()),
340            Some("src/math.gruel".to_string())
341        );
342    }
343
344    #[test]
345    fn test_resolve_simple_facade_file() {
346        let paths = ["_utils.gruel".to_string()];
347        let module = ModulePath::Simple {
348            path: "utils".to_string(),
349        };
350        assert_eq!(
351            module.resolve(paths.iter()),
352            Some("_utils.gruel".to_string())
353        );
354    }
355
356    #[test]
357    fn test_resolve_simple_prefers_regular_over_facade() {
358        // When both "foo.gruel" and "_foo.gruel" exist, prefer "foo.gruel"
359        let paths = ["_foo.gruel".to_string(), "foo.gruel".to_string()];
360        let module = ModulePath::Simple {
361            path: "foo".to_string(),
362        };
363        assert_eq!(module.resolve(paths.iter()), Some("foo.gruel".to_string()));
364    }
365
366    #[test]
367    fn test_resolve_simple_no_false_substring_match() {
368        // "math" should NOT match "mathematics.gruel"
369        let paths = ["mathematics.gruel".to_string()];
370        let module = ModulePath::Simple {
371            path: "math".to_string(),
372        };
373        // The basename "mathematics" != "math", so no match
374        assert_eq!(module.resolve(paths.iter()), None);
375    }
376
377    // =========================================================================
378    // Edge case tests
379    // =========================================================================
380
381    #[test]
382    fn test_resolve_not_found() {
383        let paths = ["other.gruel".to_string()];
384        let module = ModulePath::Simple {
385            path: "foo".to_string(),
386        };
387        assert_eq!(module.resolve(paths.iter()), None);
388    }
389
390    #[test]
391    fn test_resolve_empty_paths() {
392        let paths: Vec<String> = vec![];
393        let module = ModulePath::Simple {
394            path: "foo".to_string(),
395        };
396        assert_eq!(module.resolve(paths.iter()), None);
397    }
398}