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 dashmap::{DashMap, DashSet};
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///
89/// Uses concurrent collections (DashMap/DashSet) for thread-safe access during parallel bundling.
90#[derive(Debug)]
91pub struct CollectionState {
92    pub modules: DashMap<String, CollectedModule>,
93    pub entry_points: DashSet<String>,
94    /// Resolved entry IDs from the load hook (absolute paths)
95    pub resolved_entry_ids: DashSet<String>,
96}
97
98impl CollectionState {
99    pub fn new() -> Self {
100        Self {
101            modules: DashMap::new(),
102            entry_points: DashSet::new(),
103            resolved_entry_ids: DashSet::new(),
104        }
105    }
106
107    pub fn add_module(&self, id: String, module: CollectedModule) {
108        self.modules.insert(id, module);
109    }
110
111    pub fn get_module(
112        &self,
113        id: &str,
114    ) -> Option<dashmap::mapref::one::Ref<'_, String, CollectedModule>> {
115        self.modules.get(id)
116    }
117
118    /// Mark a module as an entry point
119    ///
120    /// Note: This method allows marking modules as entry points before they are added
121    /// to the collection, which is useful during initial setup. Entry points should be
122    /// validated after collection is complete.
123    pub fn mark_entry(&self, id: String) {
124        self.entry_points.insert(id);
125    }
126
127    /// Validate that all entry points exist in the module collection
128    ///
129    /// # Errors
130    ///
131    /// Returns `CollectionError::ModuleNotFound` for any entry point that doesn't have
132    /// a corresponding module in the collection.
133    pub fn validate_entry_points(&self) -> Result<(), CollectionError> {
134        for entry in self.entry_points.iter() {
135            if !self.modules.contains_key(entry.key()) {
136                return Err(CollectionError::ModuleNotFound(entry.key().clone()));
137            }
138        }
139        Ok(())
140    }
141}
142
143impl Default for CollectionState {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149/// Parse a JavaScript/TypeScript module to extract its import/export structure
150///
151/// Uses fob-gen's parser for consistent parsing and better error handling.
152///
153/// # Returns
154///
155/// Returns a tuple of (imports, exports, has_side_effects) where:
156/// - `imports`: List of import statements found in the module
157/// - `exports`: List of export declarations found in the module
158/// - `has_side_effects`: Conservative default of `true` (assumes side effects)
159///
160/// # Errors
161///
162/// Returns `CollectionError::ParseError` if the code contains syntax errors.
163pub fn parse_module_structure(
164    code: &str,
165) -> Result<(Vec<CollectedImport>, Vec<CollectedExport>, bool), CollectionError> {
166    use fob_gen::{ExportDeclaration, ParseOptions, QueryBuilder, parse};
167    use oxc_allocator::Allocator;
168    use oxc_ast::ast::Declaration;
169
170    let allocator = Allocator::default();
171
172    // Infer source type from code patterns - use ParseOptions helpers
173    let parse_opts = if code.contains("import ") || code.contains("export ") {
174        if code.contains(": ")
175            || code.contains("interface ")
176            || code.contains("import type ")
177            || code.contains("export type ")
178        {
179            ParseOptions::tsx() // TypeScript with JSX
180        } else {
181            ParseOptions::jsx() // JavaScript with JSX
182        }
183    } else {
184        ParseOptions::default() // Plain script
185    };
186
187    // Use fob-gen's parse function
188    let parsed = match parse(&allocator, code, parse_opts) {
189        Ok(parsed) => parsed,
190        Err(e) => {
191            return Err(CollectionError::ParseError(e.to_string()));
192        }
193    };
194
195    let mut imports = Vec::new();
196    let mut exports = Vec::new();
197    let has_side_effects = true; // Conservative default
198
199    // Use QueryBuilder to extract imports and exports
200    let query = QueryBuilder::new(&allocator, parsed.ast());
201
202    // Helper to extract name from ModuleExportName
203    fn get_module_export_name_string(name: &oxc_ast::ast::ModuleExportName) -> String {
204        match name {
205            oxc_ast::ast::ModuleExportName::IdentifierName(ident) => ident.name.to_string(),
206            oxc_ast::ast::ModuleExportName::IdentifierReference(ident) => ident.name.to_string(),
207            oxc_ast::ast::ModuleExportName::StringLiteral(lit) => lit.value.to_string(),
208        }
209    }
210
211    // Extract imports
212    for import_decl in query.find_imports(None).iter() {
213        let mut specifiers = Vec::new();
214        if let Some(specs) = &import_decl.specifiers {
215            for spec in specs {
216                match spec {
217                    oxc_ast::ast::ImportDeclarationSpecifier::ImportDefaultSpecifier(
218                        default_spec,
219                    ) => {
220                        specifiers.push(CollectedImportSpecifier::Default {
221                            local: default_spec.local.name.to_string(),
222                        });
223                    }
224                    oxc_ast::ast::ImportDeclarationSpecifier::ImportNamespaceSpecifier(ns_spec) => {
225                        specifiers.push(CollectedImportSpecifier::Namespace {
226                            local: ns_spec.local.name.to_string(),
227                        });
228                    }
229                    oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(named_spec) => {
230                        let imported = get_module_export_name_string(&named_spec.imported);
231                        specifiers.push(CollectedImportSpecifier::Named {
232                            imported,
233                            local: named_spec.local.name.to_string(),
234                        });
235                    }
236                }
237            }
238        }
239        let kind = match import_decl.import_kind {
240            oxc_ast::ast::ImportOrExportKind::Value => CollectedImportKind::Static,
241            oxc_ast::ast::ImportOrExportKind::Type => CollectedImportKind::TypeOnly,
242        };
243
244        imports.push(CollectedImport {
245            source: import_decl.source.value.to_string(),
246            specifiers,
247            kind,
248            resolved_path: None,
249        });
250    }
251
252    // Extract exports
253    for export_decl in query.find_exports().iter() {
254        match export_decl {
255            ExportDeclaration::Default(_) => {
256                exports.push(CollectedExport::Default);
257            }
258            ExportDeclaration::Named(named) => {
259                if let Some(src) = &named.source {
260                    exports.push(CollectedExport::All {
261                        source: src.value.to_string(),
262                    });
263                } else if let Some(decl) = &named.declaration {
264                    match decl {
265                        Declaration::FunctionDeclaration(func) => {
266                            if let Some(id) = &func.id {
267                                exports.push(CollectedExport::Named {
268                                    exported: id.name.to_string(),
269                                    local: Some(id.name.to_string()),
270                                });
271                            }
272                        }
273                        Declaration::VariableDeclaration(var) => {
274                            for decl in &var.declarations {
275                                if let oxc_ast::ast::BindingPatternKind::BindingIdentifier(ident) =
276                                    &decl.id.kind
277                                {
278                                    exports.push(CollectedExport::Named {
279                                        exported: ident.name.to_string(),
280                                        local: Some(ident.name.to_string()),
281                                    });
282                                }
283                            }
284                        }
285                        Declaration::ClassDeclaration(class) => {
286                            if let Some(id) = &class.id {
287                                exports.push(CollectedExport::Named {
288                                    exported: id.name.to_string(),
289                                    local: Some(id.name.to_string()),
290                                });
291                            }
292                        }
293                        _ => {}
294                    }
295                } else {
296                    // Export specifiers like `export { foo }` or `export { foo as bar }`
297                    for s in &named.specifiers {
298                        let local = get_module_export_name_string(&s.local);
299                        let exported = get_module_export_name_string(&s.exported);
300                        exports.push(CollectedExport::Named {
301                            exported,
302                            local: Some(local),
303                        });
304                    }
305                }
306            }
307            ExportDeclaration::All(all) => {
308                exports.push(CollectedExport::All {
309                    source: all.source.value.to_string(),
310                });
311            }
312        }
313    }
314
315    Ok((imports, exports, has_side_effects))
316}