Skip to main content

presolve_compiler/
module_graph.rs

1use std::collections::BTreeSet;
2use std::path::{Component, Path, PathBuf};
3
4use presolve_parser::{ParsedExport, ParsedExportKind, ParsedImport, SourceSpan};
5
6use crate::compilation_unit::CompilationUnit;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ModuleGraph {
10    pub modules: Vec<ModuleNode>,
11    pub edges: Vec<ModuleEdge>,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ModuleNode {
16    pub path: PathBuf,
17    pub imports: Vec<ParsedImport>,
18    pub exports: Vec<ParsedExport>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ModuleEdge {
23    pub source: PathBuf,
24    pub specifier: String,
25    pub target: ModuleTarget,
26    pub kind: ModuleEdgeKind,
27    pub span: SourceSpan,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
31pub enum ModuleEdgeKind {
32    Import,
33    NamedReExport,
34    ExportAll,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ModuleTarget {
39    Resolved(PathBuf),
40    External,
41    Unresolved,
42}
43
44#[must_use]
45pub fn build_module_graph(unit: &CompilationUnit) -> ModuleGraph {
46    let paths = unit
47        .files()
48        .iter()
49        .map(|file| file.path.clone())
50        .collect::<BTreeSet<_>>();
51    let mut edges = Vec::new();
52
53    for file in unit.files() {
54        for import in &file.imports {
55            edges.push(ModuleEdge {
56                source: file.path.clone(),
57                specifier: import.source.clone(),
58                target: resolve_module_target(&file.path, &import.source, &paths),
59                kind: ModuleEdgeKind::Import,
60                span: import.span,
61            });
62        }
63
64        for export in &file.exports {
65            let Some(specifier) = &export.source else {
66                continue;
67            };
68            edges.push(ModuleEdge {
69                source: file.path.clone(),
70                specifier: specifier.clone(),
71                target: resolve_module_target(&file.path, specifier, &paths),
72                kind: match export.kind {
73                    ParsedExportKind::All => ModuleEdgeKind::ExportAll,
74                    ParsedExportKind::Named | ParsedExportKind::Default => {
75                        ModuleEdgeKind::NamedReExport
76                    }
77                },
78                span: export.span,
79            });
80        }
81    }
82
83    edges.sort_by(|left, right| {
84        (&left.source, left.span.start, left.kind, &left.specifier).cmp(&(
85            &right.source,
86            right.span.start,
87            right.kind,
88            &right.specifier,
89        ))
90    });
91
92    ModuleGraph {
93        modules: unit
94            .files()
95            .iter()
96            .map(|file| ModuleNode {
97                path: file.path.clone(),
98                imports: file.imports.clone(),
99                exports: file.exports.clone(),
100            })
101            .collect(),
102        edges,
103    }
104}
105
106fn resolve_module_target(
107    source_path: &Path,
108    specifier: &str,
109    paths: &BTreeSet<PathBuf>,
110) -> ModuleTarget {
111    if !is_relative_specifier(specifier) {
112        return ModuleTarget::External;
113    }
114
115    let joined_path = source_path
116        .parent()
117        .unwrap_or_else(|| Path::new(""))
118        .join(specifier);
119    let base = normalize_path(&joined_path);
120    let mut candidates = vec![base.clone()];
121    if base.extension().is_none() {
122        for extension in ["ts", "tsx", "js", "jsx"] {
123            candidates.push(base.with_extension(extension));
124        }
125        for extension in ["ts", "tsx", "js", "jsx"] {
126            candidates.push(base.join("index").with_extension(extension));
127        }
128    }
129
130    candidates
131        .into_iter()
132        .find(|candidate| paths.contains(candidate))
133        .map_or(ModuleTarget::Unresolved, ModuleTarget::Resolved)
134}
135
136fn is_relative_specifier(specifier: &str) -> bool {
137    matches!(specifier, "." | "..") || specifier.starts_with("./") || specifier.starts_with("../")
138}
139
140fn normalize_path(path: &Path) -> PathBuf {
141    let mut normalized = PathBuf::new();
142
143    for component in path.components() {
144        match component {
145            Component::CurDir => {}
146            Component::ParentDir => {
147                normalized.pop();
148            }
149            Component::Normal(segment) => normalized.push(segment),
150            Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
151        }
152    }
153
154    normalized
155}
156
157#[cfg(test)]
158mod tests {
159    use super::{build_module_graph, ModuleEdgeKind, ModuleTarget};
160    use crate::CompilationUnit;
161
162    #[test]
163    fn derives_module_edges_from_imports_and_re_exports() {
164        let unit = CompilationUnit::parse_sources([
165            (
166                "src/app/App.tsx",
167                r#"
168import { Card as AppCard } from "../ui/Card";
169import "presolve-runtime";
170export { Status } from "./status";
171export * from "./shared";
172export default class App {}
173"#,
174            ),
175            ("src/app/status.ts", "export const Status = 200;"),
176            (
177                "src/app/shared/index.ts",
178                "export const label = \"shared\";",
179            ),
180            ("src/ui/Card.tsx", "export class Card {}"),
181        ]);
182
183        let graph = build_module_graph(&unit);
184        let app = graph.modules.first().expect("expected app module");
185
186        assert_eq!(graph.modules.len(), 4);
187        assert_eq!(app.path.to_string_lossy(), "src/app/App.tsx");
188        assert_eq!(app.imports[0].specifiers[0].imported, "Card");
189        assert_eq!(app.imports[0].specifiers[0].local, "AppCard");
190        assert_eq!(app.exports[0].specifiers[0].exported, "Status");
191        assert_eq!(app.exports[2].specifiers[0].exported, "default");
192
193        assert_eq!(graph.edges.len(), 4);
194        assert_eq!(graph.edges[0].kind, ModuleEdgeKind::Import);
195        assert_eq!(
196            graph.edges[0].target,
197            ModuleTarget::Resolved("src/ui/Card.tsx".into())
198        );
199        assert_eq!(graph.edges[1].target, ModuleTarget::External);
200        assert_eq!(graph.edges[2].kind, ModuleEdgeKind::NamedReExport);
201        assert_eq!(
202            graph.edges[2].target,
203            ModuleTarget::Resolved("src/app/status.ts".into())
204        );
205        assert_eq!(graph.edges[3].kind, ModuleEdgeKind::ExportAll);
206        assert_eq!(
207            graph.edges[3].target,
208            ModuleTarget::Resolved("src/app/shared/index.ts".into())
209        );
210    }
211
212    #[test]
213    fn distinguishes_external_and_unresolved_relative_specifiers() {
214        let unit = CompilationUnit::parse_sources([(
215            "src/App.tsx",
216            r#"
217import { packageValue } from "package-value";
218import { missing } from "./missing";
219"#,
220        )]);
221
222        let graph = build_module_graph(&unit);
223
224        assert_eq!(graph.edges[0].target, ModuleTarget::External);
225        assert_eq!(graph.edges[1].target, ModuleTarget::Unresolved);
226    }
227}