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}