fob_graph/collection.rs
1//! Shared collection types for module graph analysis.
2//!
3//! These types serve as an intermediate representation between source code
4//! and the final `ModuleGraph`. They are populated by:
5//!
6//! 1. **Bundler mode**: `ModuleCollectionPlugin` during Rolldown traversal
7//! 2. **Analysis mode**: Direct parsing via `parse_module_structure()`
8//!
9//! The `Collected*` types retain more information than their final `Module`
10//! counterparts (e.g., local bindings) to enable flexible graph construction.
11//!
12//! # Security Note
13//!
14//! `parse_module_structure()` returns errors for malformed code rather than
15//! silently accepting invalid syntax. Callers should handle parse errors
16//! appropriately for their use case.
17
18use std::collections::{HashMap, HashSet};
19use thiserror::Error;
20
21/// Errors that can occur during module collection
22#[derive(Debug, Error)]
23pub enum CollectionError {
24 /// Failed to parse module code
25 #[error("Failed to parse module: {0}")]
26 ParseError(String),
27
28 /// Module not found in collection
29 #[error("Module not found: {0}")]
30 ModuleNotFound(String),
31}
32
33/// Import kind detected during parsing
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum CollectedImportKind {
36 /// Regular static import: `import { foo } from './bar'`
37 Static,
38 /// Dynamic import expression: `import('./dynamic')`
39 Dynamic,
40 /// Type-only import: `import type { Type } from './types'`
41 TypeOnly,
42}
43
44/// Represents a collected module with all its metadata
45#[derive(Debug, Clone)]
46pub struct CollectedModule {
47 pub id: String,
48 pub code: Option<String>,
49 pub is_entry: bool,
50 pub is_external: bool,
51 pub imports: Vec<CollectedImport>,
52 pub exports: Vec<CollectedExport>,
53 pub has_side_effects: bool,
54}
55
56/// Represents an import statement in a module
57#[derive(Debug, Clone)]
58pub struct CollectedImport {
59 pub source: String,
60 pub specifiers: Vec<CollectedImportSpecifier>,
61 pub kind: CollectedImportKind,
62 /// Resolved path to the target module (relative to cwd, same format as module IDs).
63 /// None for external dependencies or unresolved imports.
64 pub resolved_path: Option<String>,
65}
66
67#[derive(Debug, Clone)]
68pub enum CollectedImportSpecifier {
69 Named { imported: String, local: String },
70 Default { local: String },
71 Namespace { local: String },
72}
73
74/// Represents an export declaration in a module
75#[derive(Debug, Clone)]
76pub enum CollectedExport {
77 Named {
78 exported: String,
79 local: Option<String>,
80 },
81 Default,
82 All {
83 source: String,
84 },
85}
86
87/// Shared state for collecting module information during bundling or analysis
88#[derive(Debug, Default)]
89pub struct CollectionState {
90 pub modules: HashMap<String, CollectedModule>,
91 pub entry_points: Vec<String>,
92 /// Resolved entry IDs from the load hook (absolute paths)
93 pub resolved_entry_ids: HashSet<String>,
94}
95
96impl CollectionState {
97 pub fn new() -> Self {
98 Self {
99 modules: HashMap::new(),
100 entry_points: Vec::new(),
101 resolved_entry_ids: HashSet::new(),
102 }
103 }
104
105 pub fn add_module(&mut self, id: String, module: CollectedModule) {
106 self.modules.insert(id, module);
107 }
108
109 pub fn get_module(&self, id: &str) -> Option<&CollectedModule> {
110 self.modules.get(id)
111 }
112
113 /// Mark a module as an entry point
114 ///
115 /// Note: This method allows marking modules as entry points before they are added
116 /// to the collection, which is useful during initial setup. Entry points should be
117 /// validated after collection is complete.
118 pub fn mark_entry(&mut self, id: String) {
119 if !self.entry_points.contains(&id) {
120 self.entry_points.push(id);
121 }
122 }
123
124 /// Validate that all entry points exist in the module collection
125 ///
126 /// # Errors
127 ///
128 /// Returns `CollectionError::ModuleNotFound` for any entry point that doesn't have
129 /// a corresponding module in the collection.
130 pub fn validate_entry_points(&self) -> Result<(), CollectionError> {
131 for entry in &self.entry_points {
132 if !self.modules.contains_key(entry) {
133 return Err(CollectionError::ModuleNotFound(entry.clone()));
134 }
135 }
136 Ok(())
137 }
138}
139
140/// Parse a JavaScript/TypeScript module to extract its import/export structure
141///
142/// Uses fob-gen's parser for consistent parsing and better error handling.
143///
144/// # Returns
145///
146/// Returns a tuple of (imports, exports, has_side_effects) where:
147/// - `imports`: List of import statements found in the module
148/// - `exports`: List of export declarations found in the module
149/// - `has_side_effects`: Conservative default of `true` (assumes side effects)
150///
151/// # Errors
152///
153/// Returns `CollectionError::ParseError` if the code contains syntax errors.
154pub fn parse_module_structure(
155 code: &str,
156) -> Result<(Vec<CollectedImport>, Vec<CollectedExport>, bool), CollectionError> {
157 use fob_gen::{ParseOptions, QueryBuilder, parse};
158 use oxc_allocator::Allocator;
159 use oxc_ast::ast::{Declaration, ModuleDeclaration};
160
161 let allocator = Allocator::default();
162
163 // Infer source type from code patterns - use ParseOptions helpers
164 let parse_opts = if code.contains("import ") || code.contains("export ") {
165 if code.contains(": ")
166 || code.contains("interface ")
167 || code.contains("import type ")
168 || code.contains("export type ")
169 {
170 ParseOptions::tsx() // TypeScript with JSX
171 } else {
172 ParseOptions::jsx() // JavaScript with JSX
173 }
174 } else {
175 ParseOptions::default() // Plain script
176 };
177
178 // Use fob-gen's parse function
179 let parsed = match parse(&allocator, code, parse_opts) {
180 Ok(parsed) => parsed,
181 Err(e) => {
182 return Err(CollectionError::ParseError(e.to_string()));
183 }
184 };
185
186 let mut imports = Vec::new();
187 let mut exports = Vec::new();
188 let has_side_effects = true; // Conservative default
189
190 // Use QueryBuilder to extract imports and exports
191 let query = QueryBuilder::new(&allocator, parsed.ast());
192
193 // Extract imports
194 let _import_query = query.find_imports(None);
195 // Note: QueryBuilder doesn't expose the actual ImportDeclaration nodes yet,
196 // so we still need to walk the AST manually, but we use the parsed program
197 for stmt in parsed.ast().body.iter() {
198 if let Some(module_decl) = stmt.as_module_declaration() {
199 match module_decl {
200 ModuleDeclaration::ImportDeclaration(import) => {
201 let mut specifiers = Vec::new();
202 if let Some(specs) = &import.specifiers {
203 for spec in specs {
204 match spec {
205 oxc_ast::ast::ImportDeclarationSpecifier::ImportDefaultSpecifier(default_spec) => {
206 specifiers.push(CollectedImportSpecifier::Default {
207 local: default_spec.local.name.to_string(),
208 });
209 }
210 oxc_ast::ast::ImportDeclarationSpecifier::ImportNamespaceSpecifier(ns_spec) => {
211 specifiers.push(CollectedImportSpecifier::Namespace {
212 local: ns_spec.local.name.to_string(),
213 });
214 }
215 oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(named_spec) => {
216 let imported = match &named_spec.imported {
217 oxc_ast::ast::ModuleExportName::IdentifierName(ident) => ident.name.to_string(),
218 oxc_ast::ast::ModuleExportName::IdentifierReference(ident) => ident.name.to_string(),
219 oxc_ast::ast::ModuleExportName::StringLiteral(lit) => lit.value.to_string(),
220 };
221 specifiers.push(CollectedImportSpecifier::Named {
222 imported,
223 local: named_spec.local.name.to_string(),
224 });
225 }
226 }
227 }
228 }
229 // Determine import kind based on OXC's import_kind field
230 let kind = match import.import_kind {
231 oxc_ast::ast::ImportOrExportKind::Value => CollectedImportKind::Static,
232 oxc_ast::ast::ImportOrExportKind::Type => CollectedImportKind::TypeOnly,
233 };
234
235 imports.push(CollectedImport {
236 source: import.source.value.to_string(),
237 specifiers,
238 kind,
239 resolved_path: None, // Will be populated during graph walking
240 });
241 }
242 ModuleDeclaration::ExportDefaultDeclaration(_) => {
243 exports.push(CollectedExport::Default);
244 }
245 ModuleDeclaration::ExportNamedDeclaration(named) => {
246 if let Some(src) = &named.source {
247 // Re-export
248 exports.push(CollectedExport::All {
249 source: src.value.to_string(),
250 });
251 } else if let Some(decl) = &named.declaration {
252 // Export declaration
253 match decl {
254 Declaration::FunctionDeclaration(func) => {
255 if let Some(id) = &func.id {
256 exports.push(CollectedExport::Named {
257 exported: id.name.to_string(),
258 local: Some(id.name.to_string()),
259 });
260 }
261 }
262 Declaration::VariableDeclaration(var) => {
263 for decl in &var.declarations {
264 if let oxc_ast::ast::BindingPatternKind::BindingIdentifier(
265 ident,
266 ) = &decl.id.kind
267 {
268 exports.push(CollectedExport::Named {
269 exported: ident.name.to_string(),
270 local: Some(ident.name.to_string()),
271 });
272 }
273 }
274 }
275 Declaration::ClassDeclaration(class) => {
276 if let Some(id) = &class.id {
277 exports.push(CollectedExport::Named {
278 exported: id.name.to_string(),
279 local: Some(id.name.to_string()),
280 });
281 }
282 }
283 _ => {}
284 }
285 }
286 }
287 ModuleDeclaration::ExportAllDeclaration(all) => {
288 exports.push(CollectedExport::All {
289 source: all.source.value.to_string(),
290 });
291 }
292 _ => {}
293 }
294 }
295 }
296
297 Ok((imports, exports, has_side_effects))
298}