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::{ParseOptions, QueryBuilder, parse};
167    use oxc_allocator::Allocator;
168    use oxc_ast::ast::{Declaration, ModuleDeclaration};
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    // Extract imports
203    let _import_query = query.find_imports(None);
204    // Note: QueryBuilder doesn't expose the actual ImportDeclaration nodes yet,
205    // so we still need to walk the AST manually, but we use the parsed program
206    for stmt in parsed.ast().body.iter() {
207        if let Some(module_decl) = stmt.as_module_declaration() {
208            match module_decl {
209                ModuleDeclaration::ImportDeclaration(import) => {
210                    let mut specifiers = Vec::new();
211                    if let Some(specs) = &import.specifiers {
212                        for spec in specs {
213                            match spec {
214                                oxc_ast::ast::ImportDeclarationSpecifier::ImportDefaultSpecifier(default_spec) => {
215                                    specifiers.push(CollectedImportSpecifier::Default {
216                                        local: default_spec.local.name.to_string(),
217                                    });
218                                }
219                                oxc_ast::ast::ImportDeclarationSpecifier::ImportNamespaceSpecifier(ns_spec) => {
220                                    specifiers.push(CollectedImportSpecifier::Namespace {
221                                        local: ns_spec.local.name.to_string(),
222                                    });
223                                }
224                                oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(named_spec) => {
225                                    let imported = match &named_spec.imported {
226                                        oxc_ast::ast::ModuleExportName::IdentifierName(ident) => ident.name.to_string(),
227                                        oxc_ast::ast::ModuleExportName::IdentifierReference(ident) => ident.name.to_string(),
228                                        oxc_ast::ast::ModuleExportName::StringLiteral(lit) => lit.value.to_string(),
229                                    };
230                                    specifiers.push(CollectedImportSpecifier::Named {
231                                        imported,
232                                        local: named_spec.local.name.to_string(),
233                                    });
234                                }
235                            }
236                        }
237                    }
238                    // Determine import kind based on OXC's import_kind field
239                    let kind = match import.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.source.value.to_string(),
246                        specifiers,
247                        kind,
248                        resolved_path: None, // Will be populated during graph walking
249                    });
250                }
251                ModuleDeclaration::ExportDefaultDeclaration(_) => {
252                    exports.push(CollectedExport::Default);
253                }
254                ModuleDeclaration::ExportNamedDeclaration(named) => {
255                    if let Some(src) = &named.source {
256                        // Re-export
257                        exports.push(CollectedExport::All {
258                            source: src.value.to_string(),
259                        });
260                    } else if let Some(decl) = &named.declaration {
261                        // Export declaration
262                        match decl {
263                            Declaration::FunctionDeclaration(func) => {
264                                if let Some(id) = &func.id {
265                                    exports.push(CollectedExport::Named {
266                                        exported: id.name.to_string(),
267                                        local: Some(id.name.to_string()),
268                                    });
269                                }
270                            }
271                            Declaration::VariableDeclaration(var) => {
272                                for decl in &var.declarations {
273                                    if let oxc_ast::ast::BindingPatternKind::BindingIdentifier(
274                                        ident,
275                                    ) = &decl.id.kind
276                                    {
277                                        exports.push(CollectedExport::Named {
278                                            exported: ident.name.to_string(),
279                                            local: Some(ident.name.to_string()),
280                                        });
281                                    }
282                                }
283                            }
284                            Declaration::ClassDeclaration(class) => {
285                                if let Some(id) = &class.id {
286                                    exports.push(CollectedExport::Named {
287                                        exported: id.name.to_string(),
288                                        local: Some(id.name.to_string()),
289                                    });
290                                }
291                            }
292                            _ => {}
293                        }
294                    }
295                }
296                ModuleDeclaration::ExportAllDeclaration(all) => {
297                    exports.push(CollectedExport::All {
298                        source: all.source.value.to_string(),
299                    });
300                }
301                _ => {}
302            }
303        }
304    }
305
306    Ok((imports, exports, has_side_effects))
307}