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}