Skip to main content

oxilean_parse/module_system/
functions.rs

1//! Module system functions: path parsing, resolution, graph analysis.
2
3use super::types::{
4    CycleError, ImportDecl, ModuleGraph, ModuleInfo, ModulePath, ModuleRegistry,
5    ModuleResolutionResult,
6};
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::path::PathBuf;
9use std::str::FromStr;
10
11// ─────────────────────────────────────────────────────────────────────────────
12// ModulePath — FromStr + helpers
13// ─────────────────────────────────────────────────────────────────────────────
14
15/// Error returned when a module path string cannot be parsed.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ModulePathParseError(pub String);
18
19impl std::fmt::Display for ModulePathParseError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        write!(f, "invalid module path: {:?}", self.0)
22    }
23}
24
25impl std::error::Error for ModulePathParseError {}
26
27impl FromStr for ModulePath {
28    type Err = ModulePathParseError;
29
30    fn from_str(s: &str) -> Result<Self, Self::Err> {
31        if s.is_empty() {
32            return Err(ModulePathParseError(s.to_owned()));
33        }
34        let components: Vec<String> = s.split('.').map(|c| c.to_owned()).collect();
35        if components.iter().any(|c| c.is_empty()) {
36            return Err(ModulePathParseError(s.to_owned()));
37        }
38        Ok(Self { components })
39    }
40}
41
42impl ModulePath {
43    /// Parse a dot-separated string into a [`ModulePath`], returning `None` on
44    /// failure.  Convenience wrapper around [`FromStr`].
45    pub fn parse(s: &str) -> Option<Self> {
46        s.parse().ok()
47    }
48
49    /// Convert to a relative file-system path with a `.lean` extension.
50    /// The returned path is *relative* — callers must prefix with a root.
51    ///
52    /// `Mathlib.Algebra.Ring` → `Mathlib/Algebra/Ring.lean`
53    pub fn to_file_path(&self) -> PathBuf {
54        let mut p: PathBuf = self.components.iter().collect();
55        p.set_extension("lean");
56        p
57    }
58
59    /// Same as [`Self::to_file_path`] but with the `.oxilean` extension.
60    pub fn to_oxilean_file_path(&self) -> PathBuf {
61        let mut p: PathBuf = self.components.iter().collect();
62        p.set_extension("oxilean");
63        p
64    }
65}
66
67// ─────────────────────────────────────────────────────────────────────────────
68// ModuleRegistry methods
69// ─────────────────────────────────────────────────────────────────────────────
70
71impl ModuleRegistry {
72    /// Create a new registry with the given search roots.
73    pub fn new(roots: Vec<PathBuf>) -> Self {
74        Self {
75            roots,
76            cache: HashMap::new(),
77        }
78    }
79
80    /// Look up `path` in the cache; if absent, search the root directories for
81    /// a matching `.lean` or `.oxilean` file.  Populates the cache on success.
82    pub fn resolve(&mut self, path: &ModulePath) -> ModuleResolutionResult {
83        if let Some(info) = self.cache.get(path) {
84            return ModuleResolutionResult::Found(info.clone());
85        }
86
87        let lean_rel = path.to_file_path();
88        let oxilean_rel = path.to_oxilean_file_path();
89
90        for root in &self.roots {
91            let lean_abs = root.join(&lean_rel);
92            let oxilean_abs = root.join(&oxilean_rel);
93
94            let found = if lean_abs.exists() {
95                Some(lean_abs)
96            } else if oxilean_abs.exists() {
97                Some(oxilean_abs)
98            } else {
99                None
100            };
101
102            if let Some(file_path) = found {
103                let info = ModuleInfo {
104                    path: file_path,
105                    exports: Vec::new(),
106                    dependencies: Vec::new(),
107                };
108                self.cache.insert(path.clone(), info.clone());
109                return ModuleResolutionResult::Found(info);
110            }
111        }
112
113        ModuleResolutionResult::NotFound(path.clone())
114    }
115
116    /// Manually register a module (bypasses file-system search).
117    pub fn register(&mut self, path: ModulePath, info: ModuleInfo) {
118        self.cache.insert(path, info);
119    }
120}
121
122// ─────────────────────────────────────────────────────────────────────────────
123// Import parsing
124// ─────────────────────────────────────────────────────────────────────────────
125
126/// Parse a single `import` line.
127///
128/// Supported syntaxes:
129/// - `import Mathlib.Algebra.Ring`
130/// - `import Mathlib.Algebra.Ring as MAR`
131/// - `import Mathlib.Algebra.Ring (add, mul)`
132///
133/// Returns `None` if the line does not start with `import` or the module path
134/// is malformed.
135pub fn parse_import_decl(input: &str) -> Option<ImportDecl> {
136    let trimmed = input.trim();
137    let rest = trimmed.strip_prefix("import")?.trim_start();
138    if rest.is_empty() {
139        return None;
140    }
141
142    // Check for selective import: `Foo.Bar (f, g)`
143    if let Some(paren_pos) = rest.find('(') {
144        let module_str = rest[..paren_pos].trim();
145        let module = ModulePath::parse(module_str)?;
146
147        let after_open = rest[paren_pos + 1..].trim_end_matches(|c: char| c.is_whitespace());
148        let inner = after_open.strip_suffix(')')?;
149        let selective: Vec<String> = inner
150            .split(',')
151            .map(|s| s.trim().to_owned())
152            .filter(|s| !s.is_empty())
153            .collect();
154
155        return Some(ImportDecl {
156            module,
157            alias: None,
158            selective,
159        });
160    }
161
162    // Check for aliased import: `Foo.Bar as X`
163    //   We split on whitespace and look for "as" keyword.
164    let tokens: Vec<&str> = rest.split_whitespace().collect();
165    match tokens.as_slice() {
166        [module_str] => {
167            let module = ModulePath::parse(module_str)?;
168            Some(ImportDecl {
169                module,
170                alias: None,
171                selective: Vec::new(),
172            })
173        }
174        [module_str, "as", alias] => {
175            let module = ModulePath::parse(module_str)?;
176            Some(ImportDecl {
177                module,
178                alias: Some((*alias).to_owned()),
179                selective: Vec::new(),
180            })
181        }
182        _ => None,
183    }
184}
185
186/// Extract all `import` declarations from a source file.
187///
188/// Lines that do not parse as valid import declarations are silently ignored.
189pub fn parse_imports(source: &str) -> Vec<ImportDecl> {
190    source
191        .lines()
192        .filter_map(|line| parse_import_decl(line.trim()))
193        .collect()
194}
195
196// ─────────────────────────────────────────────────────────────────────────────
197// Graph construction
198// ─────────────────────────────────────────────────────────────────────────────
199
200/// Build a [`ModuleGraph`] from all modules currently registered in `registry`.
201pub fn build_module_graph(registry: &ModuleRegistry) -> ModuleGraph {
202    let mut graph = ModuleGraph::default();
203
204    for (module_path, info) in &registry.cache {
205        graph.nodes.insert(module_path.clone(), info.clone());
206        for dep in &info.dependencies {
207            graph.edges.push((module_path.clone(), dep.clone()));
208        }
209    }
210
211    graph
212}
213
214// ─────────────────────────────────────────────────────────────────────────────
215// Graph queries
216// ─────────────────────────────────────────────────────────────────────────────
217
218/// Return the direct dependencies of `module` in `graph`.
219pub fn direct_deps_of<'a>(graph: &'a ModuleGraph, module: &ModulePath) -> Vec<&'a ModulePath> {
220    graph
221        .edges
222        .iter()
223        .filter_map(|(from, to)| if from == module { Some(to) } else { None })
224        .collect()
225}
226
227/// Return all modules transitively reachable from `module` (excluding itself).
228///
229/// Uses BFS over the dependency edges.
230pub fn transitive_deps(graph: &ModuleGraph, module: &ModulePath) -> Vec<ModulePath> {
231    let mut visited: HashSet<ModulePath> = HashSet::new();
232    let mut queue: VecDeque<ModulePath> = VecDeque::new();
233
234    for dep in direct_deps_of(graph, module) {
235        if !visited.contains(dep) {
236            visited.insert(dep.clone());
237            queue.push_back(dep.clone());
238        }
239    }
240
241    while let Some(current) = queue.pop_front() {
242        for dep in direct_deps_of(graph, &current) {
243            if !visited.contains(dep) {
244                visited.insert(dep.clone());
245                queue.push_back(dep.clone());
246            }
247        }
248    }
249
250    let mut result: Vec<ModulePath> = visited.into_iter().collect();
251    result.sort();
252    result
253}
254
255// ─────────────────────────────────────────────────────────────────────────────
256// Topological sort (Kahn's algorithm)
257// ─────────────────────────────────────────────────────────────────────────────
258
259/// Produce a topological ordering of modules in `graph` so that every module
260/// appears after all its dependencies.
261///
262/// Returns `Err(CycleError)` if a cycle exists (reports the first cycle
263/// detected via DFS back-edge).
264pub fn topological_sort(graph: &ModuleGraph) -> Result<Vec<ModulePath>, CycleError> {
265    // Build adjacency and in-degree maps over the nodes present in the graph.
266    let mut in_degree: HashMap<&ModulePath, usize> = HashMap::new();
267    let mut adjacency: HashMap<&ModulePath, Vec<&ModulePath>> = HashMap::new();
268
269    for node in graph.nodes.keys() {
270        in_degree.entry(node).or_insert(0);
271        adjacency.entry(node).or_default();
272    }
273
274    for (from, to) in &graph.edges {
275        *in_degree.entry(to).or_insert(0) += 1;
276        adjacency.entry(from).or_default().push(to);
277        // Ensure `to` has an in-degree entry even if it has no outgoing edges.
278        adjacency.entry(to).or_default();
279    }
280
281    // Kahn's algorithm.
282    let mut queue: VecDeque<&ModulePath> = in_degree
283        .iter()
284        .filter_map(|(k, &v)| if v == 0 { Some(*k) } else { None })
285        .collect();
286
287    // Deterministic order.
288    let mut queue_vec: Vec<&ModulePath> = queue.drain(..).collect();
289    queue_vec.sort();
290    let mut queue: VecDeque<&ModulePath> = queue_vec.into_iter().collect();
291
292    let mut sorted: Vec<ModulePath> = Vec::new();
293
294    while let Some(node) = queue.pop_front() {
295        sorted.push(node.clone());
296        if let Some(neighbors) = adjacency.get(node) {
297            let mut next_batch: Vec<&ModulePath> = Vec::new();
298            for &neighbor in neighbors {
299                let deg = in_degree.entry(neighbor).or_insert(0);
300                if *deg > 0 {
301                    *deg -= 1;
302                    if *deg == 0 {
303                        next_batch.push(neighbor);
304                    }
305                }
306            }
307            next_batch.sort();
308            for n in next_batch {
309                queue.push_back(n);
310            }
311        }
312    }
313
314    if sorted.len() != graph.nodes.len() {
315        // There are nodes not reachable by Kahn's — they are part of a cycle.
316        let in_cycle: Vec<ModulePath> = graph
317            .nodes
318            .keys()
319            .filter(|n| !sorted.contains(n))
320            .cloned()
321            .collect();
322
323        // Find an actual cycle path using DFS.
324        let cycle = find_one_cycle(graph, &in_cycle);
325        return Err(CycleError { cycle });
326    }
327
328    Ok(sorted)
329}
330
331/// Find a single cycle among `candidates` using DFS.
332fn find_one_cycle(graph: &ModuleGraph, candidates: &[ModulePath]) -> Vec<ModulePath> {
333    let candidate_set: HashSet<&ModulePath> = candidates.iter().collect();
334    let mut visited: HashSet<&ModulePath> = HashSet::new();
335    let mut stack: Vec<&ModulePath> = Vec::new();
336
337    for start in candidates {
338        if !visited.contains(start) {
339            if let Some(cycle) = dfs_cycle(graph, start, &candidate_set, &mut visited, &mut stack) {
340                return cycle;
341            }
342        }
343    }
344
345    // Fallback — return candidates as-is (should not happen for a cyclic graph).
346    candidates.to_vec()
347}
348
349fn dfs_cycle<'a>(
350    graph: &'a ModuleGraph,
351    node: &'a ModulePath,
352    candidates: &HashSet<&'a ModulePath>,
353    visited: &mut HashSet<&'a ModulePath>,
354    stack: &mut Vec<&'a ModulePath>,
355) -> Option<Vec<ModulePath>> {
356    visited.insert(node);
357    stack.push(node);
358
359    for (from, to) in &graph.edges {
360        if from != node {
361            continue;
362        }
363        if !candidates.contains(to) {
364            continue;
365        }
366        if let Some(pos) = stack.iter().position(|&s| s == to) {
367            // Found back edge — extract the cycle.
368            let cycle: Vec<ModulePath> = stack[pos..].iter().map(|&p| p.clone()).collect();
369            return Some(cycle);
370        }
371        if !visited.contains(to) {
372            if let Some(cycle) = dfs_cycle(graph, to, candidates, visited, stack) {
373                return Some(cycle);
374            }
375        }
376    }
377
378    stack.pop();
379    None
380}
381
382// ─────────────────────────────────────────────────────────────────────────────
383// Cycle detection (all cycles)
384// ─────────────────────────────────────────────────────────────────────────────
385
386/// Detect *all* import cycles in `graph`.
387///
388/// Uses Johnson's algorithm skeleton: for each SCC of size > 1 (or with a
389/// self-loop), reports a cycle.  We use Tarjan's SCC here and then extract
390/// one representative cycle path per SCC.
391pub fn detect_cycles(graph: &ModuleGraph) -> Vec<CycleError> {
392    let sccs = tarjan_sccs(graph);
393    let mut errors: Vec<CycleError> = Vec::new();
394
395    for scc in sccs {
396        if scc.len() == 1 {
397            // Self-loop?
398            let node = &scc[0];
399            let self_loop = graph
400                .edges
401                .iter()
402                .any(|(from, to)| from == node && to == node);
403            if !self_loop {
404                continue;
405            }
406        }
407        // Extract representative cycle path within this SCC.
408        let cycle = find_one_cycle(graph, &scc);
409        errors.push(CycleError { cycle });
410    }
411
412    errors
413}
414
415/// Tarjan's strongly connected components algorithm.
416fn tarjan_sccs(graph: &ModuleGraph) -> Vec<Vec<ModulePath>> {
417    struct State<'a> {
418        index_counter: usize,
419        stack: Vec<&'a ModulePath>,
420        on_stack: HashSet<&'a ModulePath>,
421        index: HashMap<&'a ModulePath, usize>,
422        lowlink: HashMap<&'a ModulePath, usize>,
423        sccs: Vec<Vec<ModulePath>>,
424    }
425
426    fn strongconnect<'a>(v: &'a ModulePath, graph: &'a ModuleGraph, state: &mut State<'a>) {
427        let idx = state.index_counter;
428        state.index.insert(v, idx);
429        state.lowlink.insert(v, idx);
430        state.index_counter += 1;
431        state.stack.push(v);
432        state.on_stack.insert(v);
433
434        for (from, to) in &graph.edges {
435            if from != v {
436                continue;
437            }
438            if !graph.nodes.contains_key(to) {
439                continue;
440            }
441            if !state.index.contains_key(to) {
442                strongconnect(to, graph, state);
443                let ll_to = *state.lowlink.get(to).unwrap_or(&usize::MAX);
444                let ll_v = state.lowlink.get(v).copied().unwrap_or(usize::MAX);
445                state.lowlink.insert(v, ll_v.min(ll_to));
446            } else if state.on_stack.contains(to) {
447                let idx_to = *state.index.get(to).unwrap_or(&usize::MAX);
448                let ll_v = state.lowlink.get(v).copied().unwrap_or(usize::MAX);
449                state.lowlink.insert(v, ll_v.min(idx_to));
450            }
451        }
452
453        // If v is a root of an SCC, pop the stack.
454        let ll_v = *state.lowlink.get(v).unwrap_or(&usize::MAX);
455        let idx_v = *state.index.get(v).unwrap_or(&usize::MAX);
456        if ll_v == idx_v {
457            let mut scc: Vec<ModulePath> = Vec::new();
458            while let Some(w) = state.stack.pop() {
459                state.on_stack.remove(w);
460                scc.push(w.clone());
461                if w == v {
462                    break;
463                }
464            }
465            state.sccs.push(scc);
466        }
467    }
468
469    let mut state = State {
470        index_counter: 0,
471        stack: Vec::new(),
472        on_stack: HashSet::new(),
473        index: HashMap::new(),
474        lowlink: HashMap::new(),
475        sccs: Vec::new(),
476    };
477
478    let mut nodes: Vec<&ModulePath> = graph.nodes.keys().collect();
479    nodes.sort();
480
481    for node in nodes {
482        if !state.index.contains_key(node) {
483            strongconnect(node, graph, &mut state);
484        }
485    }
486
487    state.sccs
488}
489
490// ─────────────────────────────────────────────────────────────────────────────
491// Tests
492// ─────────────────────────────────────────────────────────────────────────────
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use std::env;
498    use std::fs;
499
500    // ── helpers ──────────────────────────────────────────────────────────────
501
502    fn mp(s: &str) -> ModulePath {
503        ModulePath::parse(s).expect("valid module path")
504    }
505
506    fn info_with_deps(deps: &[&str]) -> ModuleInfo {
507        ModuleInfo {
508            path: PathBuf::from("dummy.lean"),
509            exports: Vec::new(),
510            dependencies: deps.iter().map(|s| mp(s)).collect(),
511        }
512    }
513
514    fn graph_from_edges(pairs: &[(&str, &str)]) -> ModuleGraph {
515        let mut g = ModuleGraph::default();
516        for &(from, to) in pairs {
517            let f = mp(from);
518            let t = mp(to);
519            g.nodes
520                .entry(f.clone())
521                .or_insert_with(|| info_with_deps(&[]));
522            g.nodes
523                .entry(t.clone())
524                .or_insert_with(|| info_with_deps(&[]));
525            g.edges.push((f, t));
526        }
527        g
528    }
529
530    // ── ModulePath::parse ─────────────────────────────────────────────────
531
532    #[test]
533    fn test_module_path_from_str_single() {
534        let p = ModulePath::parse("Std").expect("valid");
535        assert_eq!(p.components, vec!["Std"]);
536    }
537
538    #[test]
539    fn test_module_path_from_str_multi() {
540        let p = mp("Mathlib.Algebra.Ring");
541        assert_eq!(p.components, vec!["Mathlib", "Algebra", "Ring"]);
542    }
543
544    #[test]
545    fn test_module_path_from_str_empty_is_none() {
546        assert!(ModulePath::parse("").is_none());
547    }
548
549    #[test]
550    fn test_module_path_from_str_double_dot_is_none() {
551        assert!(ModulePath::parse("A..B").is_none());
552    }
553
554    #[test]
555    fn test_module_path_from_str_trailing_dot_is_none() {
556        assert!(ModulePath::parse("A.B.").is_none());
557    }
558
559    // ── ModulePath::to_file_path ─────────────────────────────────────────────
560
561    #[test]
562    fn test_to_file_path_single() {
563        let p = mp("Std");
564        assert_eq!(p.to_file_path(), PathBuf::from("Std.lean"));
565    }
566
567    #[test]
568    fn test_to_file_path_multi() {
569        let p = mp("Mathlib.Algebra.Ring");
570        assert_eq!(p.to_file_path(), PathBuf::from("Mathlib/Algebra/Ring.lean"));
571    }
572
573    #[test]
574    fn test_to_oxilean_file_path() {
575        let p = mp("Foo.Bar");
576        assert_eq!(p.to_oxilean_file_path(), PathBuf::from("Foo/Bar.oxilean"));
577    }
578
579    // ── ModulePath::to_string ────────────────────────────────────────────────
580
581    #[test]
582    fn test_to_string() {
583        let p = mp("A.B.C");
584        assert_eq!(p.to_string(), "A.B.C");
585    }
586
587    #[test]
588    fn test_display() {
589        let p = mp("X.Y");
590        assert_eq!(format!("{p}"), "X.Y");
591    }
592
593    // ── parse_import_decl ────────────────────────────────────────────────────
594
595    #[test]
596    fn test_parse_import_bare() {
597        let decl = parse_import_decl("import Foo.Bar").expect("valid");
598        assert_eq!(decl.module, mp("Foo.Bar"));
599        assert!(decl.alias.is_none());
600        assert!(decl.selective.is_empty());
601    }
602
603    #[test]
604    fn test_parse_import_alias() {
605        let decl = parse_import_decl("import Foo.Bar as FB").expect("valid");
606        assert_eq!(decl.module, mp("Foo.Bar"));
607        assert_eq!(decl.alias.as_deref(), Some("FB"));
608        assert!(decl.selective.is_empty());
609    }
610
611    #[test]
612    fn test_parse_import_selective() {
613        let decl = parse_import_decl("import Foo.Bar (add, mul)").expect("valid");
614        assert_eq!(decl.module, mp("Foo.Bar"));
615        assert!(decl.alias.is_none());
616        assert_eq!(decl.selective, vec!["add", "mul"]);
617    }
618
619    #[test]
620    fn test_parse_import_leading_whitespace() {
621        let decl = parse_import_decl("  import Std").expect("valid");
622        assert_eq!(decl.module, mp("Std"));
623    }
624
625    #[test]
626    fn test_parse_import_not_import() {
627        assert!(parse_import_decl("def foo := 1").is_none());
628    }
629
630    #[test]
631    fn test_parse_import_empty_line() {
632        assert!(parse_import_decl("").is_none());
633    }
634
635    // ── parse_imports ────────────────────────────────────────────────────────
636
637    #[test]
638    fn test_parse_imports_multiple() {
639        let src = r#"
640import Std
641import Mathlib.Algebra.Ring as MAR
642def foo := 1
643import Foo (bar, baz)
644"#;
645        let imports = parse_imports(src);
646        assert_eq!(imports.len(), 3);
647        assert_eq!(imports[0].module, mp("Std"));
648        assert_eq!(imports[1].alias.as_deref(), Some("MAR"));
649        assert_eq!(imports[2].selective, vec!["bar", "baz"]);
650    }
651
652    #[test]
653    fn test_parse_imports_empty_source() {
654        assert!(parse_imports("").is_empty());
655    }
656
657    // ── ModuleRegistry ───────────────────────────────────────────────────────
658
659    #[test]
660    fn test_registry_new_empty() {
661        let reg = ModuleRegistry::new(vec![]);
662        assert!(reg.cache.is_empty());
663    }
664
665    #[test]
666    fn test_registry_register_and_resolve() {
667        let mut reg = ModuleRegistry::new(vec![]);
668        let path = mp("Foo.Bar");
669        let info = ModuleInfo {
670            path: PathBuf::from("Foo/Bar.lean"),
671            exports: vec!["baz".to_owned()],
672            dependencies: vec![],
673        };
674        reg.register(path.clone(), info.clone());
675        match reg.resolve(&path) {
676            ModuleResolutionResult::Found(i) => assert_eq!(i.path, PathBuf::from("Foo/Bar.lean")),
677            other => panic!("expected Found, got {other:?}"),
678        }
679    }
680
681    #[test]
682    fn test_registry_not_found() {
683        let mut reg = ModuleRegistry::new(vec![]);
684        let path = mp("Nonexistent");
685        assert_eq!(reg.resolve(&path), ModuleResolutionResult::NotFound(path));
686    }
687
688    #[test]
689    fn test_registry_resolve_from_filesystem() {
690        let tmp = env::temp_dir().join("oxilean_test_registry_resolve");
691        fs::create_dir_all(&tmp).expect("create temp dir");
692        let module_dir = tmp.join("Foo");
693        fs::create_dir_all(&module_dir).expect("create Foo dir");
694        let lean_file = module_dir.join("Bar.lean");
695        fs::write(&lean_file, "-- empty").expect("write lean file");
696
697        let mut reg = ModuleRegistry::new(vec![tmp.clone()]);
698        let path = mp("Foo.Bar");
699        match reg.resolve(&path) {
700            ModuleResolutionResult::Found(info) => {
701                assert!(info.path.ends_with("Foo/Bar.lean"));
702            }
703            other => panic!("expected Found, got {other:?}"),
704        }
705
706        // Clean up.
707        let _ = fs::remove_dir_all(&tmp);
708    }
709
710    #[test]
711    fn test_registry_resolve_oxilean_extension() {
712        let tmp = env::temp_dir().join("oxilean_test_registry_oxilean_ext");
713        fs::create_dir_all(&tmp).expect("create temp dir");
714        let module_dir = tmp.join("Ox");
715        fs::create_dir_all(&module_dir).expect("create dir");
716        let file = module_dir.join("Mod.oxilean");
717        fs::write(&file, "-- empty").expect("write file");
718
719        let mut reg = ModuleRegistry::new(vec![tmp.clone()]);
720        let path = mp("Ox.Mod");
721        match reg.resolve(&path) {
722            ModuleResolutionResult::Found(info) => {
723                assert!(info.path.ends_with("Ox/Mod.oxilean"));
724            }
725            other => panic!("expected Found, got {other:?}"),
726        }
727
728        let _ = fs::remove_dir_all(&tmp);
729    }
730
731    #[test]
732    fn test_registry_cache_hit() {
733        let tmp = env::temp_dir().join("oxilean_test_cache_hit");
734        fs::create_dir_all(&tmp).expect("create temp dir");
735        let file = tmp.join("Cache.lean");
736        fs::write(&file, "").expect("write");
737
738        let mut reg = ModuleRegistry::new(vec![tmp.clone()]);
739        let path = mp("Cache");
740
741        // First resolution populates cache.
742        let r1 = reg.resolve(&path);
743        // Delete the file — second resolution must still succeed from cache.
744        let _ = fs::remove_file(&file);
745        let r2 = reg.resolve(&path);
746
747        assert_eq!(r1, r2);
748        let _ = fs::remove_dir_all(&tmp);
749    }
750
751    // ── build_module_graph ───────────────────────────────────────────────────
752
753    #[test]
754    fn test_build_module_graph_empty() {
755        let reg = ModuleRegistry::new(vec![]);
756        let g = build_module_graph(&reg);
757        assert!(g.nodes.is_empty());
758        assert!(g.edges.is_empty());
759    }
760
761    #[test]
762    fn test_build_module_graph_nodes_and_edges() {
763        let mut reg = ModuleRegistry::new(vec![]);
764        reg.register(
765            mp("A"),
766            ModuleInfo {
767                path: PathBuf::from("A.lean"),
768                exports: vec![],
769                dependencies: vec![mp("B")],
770            },
771        );
772        reg.register(
773            mp("B"),
774            ModuleInfo {
775                path: PathBuf::from("B.lean"),
776                exports: vec![],
777                dependencies: vec![],
778            },
779        );
780        let g = build_module_graph(&reg);
781        assert_eq!(g.nodes.len(), 2);
782        assert!(g.edges.contains(&(mp("A"), mp("B"))));
783    }
784
785    // ── direct_deps_of ───────────────────────────────────────────────────────
786
787    #[test]
788    fn test_direct_deps_of_empty() {
789        let g = graph_from_edges(&[]);
790        let deps = direct_deps_of(&g, &mp("A"));
791        assert!(deps.is_empty());
792    }
793
794    #[test]
795    fn test_direct_deps_of_one() {
796        let g = graph_from_edges(&[("A", "B"), ("A", "C"), ("B", "C")]);
797        let deps = direct_deps_of(&g, &mp("A"));
798        let mut names: Vec<String> = deps.iter().map(|p| p.to_string()).collect();
799        names.sort();
800        assert_eq!(names, vec!["B", "C"]);
801    }
802
803    // ── transitive_deps ──────────────────────────────────────────────────────
804
805    #[test]
806    fn test_transitive_deps_chain() {
807        // A → B → C
808        let g = graph_from_edges(&[("A", "B"), ("B", "C")]);
809        let mut deps = transitive_deps(&g, &mp("A"));
810        deps.sort();
811        assert!(deps.contains(&mp("B")));
812        assert!(deps.contains(&mp("C")));
813        assert!(!deps.contains(&mp("A")));
814    }
815
816    #[test]
817    fn test_transitive_deps_diamond() {
818        // A → B, A → C, B → D, C → D
819        let g = graph_from_edges(&[("A", "B"), ("A", "C"), ("B", "D"), ("C", "D")]);
820        let deps = transitive_deps(&g, &mp("A"));
821        assert!(deps.contains(&mp("B")));
822        assert!(deps.contains(&mp("C")));
823        assert!(deps.contains(&mp("D")));
824        assert_eq!(deps.len(), 3);
825    }
826
827    // ── topological_sort ─────────────────────────────────────────────────────
828
829    #[test]
830    fn test_topo_sort_empty() {
831        let g = ModuleGraph::default();
832        let sorted = topological_sort(&g).expect("no cycle");
833        assert!(sorted.is_empty());
834    }
835
836    #[test]
837    fn test_topo_sort_chain() {
838        // A → B → C  (A imports B, B imports C)
839        // Kahn's algorithm (importer-first): nodes with no incoming edges come
840        // first.  A has no incoming edges → A first, then B, then C.
841        let g = graph_from_edges(&[("A", "B"), ("B", "C")]);
842        let sorted = topological_sort(&g).expect("no cycle");
843        let pos_a = sorted
844            .iter()
845            .position(|p| p == &mp("A"))
846            .expect("A present");
847        let pos_b = sorted
848            .iter()
849            .position(|p| p == &mp("B"))
850            .expect("B present");
851        let pos_c = sorted
852            .iter()
853            .position(|p| p == &mp("C"))
854            .expect("C present");
855        // A comes before B, and B comes before C (importer precedes importee).
856        assert!(pos_a < pos_b, "A should precede B; sorted={sorted:?}");
857        assert!(pos_b < pos_c, "B should precede C; sorted={sorted:?}");
858    }
859
860    #[test]
861    fn test_topo_sort_cycle_returns_err() {
862        // A → B → A
863        let g = graph_from_edges(&[("A", "B"), ("B", "A")]);
864        assert!(topological_sort(&g).is_err());
865    }
866
867    // ── detect_cycles ────────────────────────────────────────────────────────
868
869    #[test]
870    fn test_detect_cycles_none() {
871        let g = graph_from_edges(&[("A", "B"), ("B", "C")]);
872        assert!(detect_cycles(&g).is_empty());
873    }
874
875    #[test]
876    fn test_detect_cycles_simple() {
877        // A → B → A
878        let g = graph_from_edges(&[("A", "B"), ("B", "A")]);
879        let cycles = detect_cycles(&g);
880        assert!(!cycles.is_empty());
881        // The cycle should contain both A and B.
882        let all_nodes: Vec<ModulePath> = cycles.into_iter().flat_map(|c| c.cycle).collect();
883        assert!(all_nodes.contains(&mp("A")) || all_nodes.contains(&mp("B")));
884    }
885
886    #[test]
887    fn test_detect_cycles_self_loop() {
888        let mut g = ModuleGraph::default();
889        g.nodes.insert(mp("A"), info_with_deps(&[]));
890        g.edges.push((mp("A"), mp("A")));
891        let cycles = detect_cycles(&g);
892        assert!(!cycles.is_empty());
893    }
894
895    #[test]
896    fn test_detect_cycles_two_distinct() {
897        // A → B → A  and  C → D → C
898        let g = graph_from_edges(&[("A", "B"), ("B", "A"), ("C", "D"), ("D", "C")]);
899        let cycles = detect_cycles(&g);
900        // At least two SCCs with cycles.
901        assert!(cycles.len() >= 2);
902    }
903
904    // ── CycleError display ───────────────────────────────────────────────────
905
906    #[test]
907    fn test_cycle_error_display() {
908        let err = CycleError {
909            cycle: vec![mp("A"), mp("B"), mp("C")],
910        };
911        let s = format!("{err}");
912        assert!(s.contains("A"));
913        assert!(s.contains("B"));
914        assert!(s.contains("C"));
915    }
916}