Skip to main content

fallow_graph/graph/
types.rs

1//! Shared graph types: module nodes, re-export edges, export symbols, and references.
2
3use std::ops::Range;
4use std::path::PathBuf;
5
6use fallow_types::discover::FileId;
7use fallow_types::extract::{ExportName, VisibilityTag};
8
9/// A single module in the graph.
10///
11/// Boolean flags are packed into a `u8` to keep the struct at 96 bytes
12/// (down from 104 with 5 separate `bool` fields), improving cache line
13/// utilization in hot graph traversal loops.
14#[derive(Debug)]
15pub struct ModuleNode {
16    /// Unique identifier for this module.
17    pub file_id: FileId,
18    /// Absolute path to the module file.
19    pub path: PathBuf,
20    /// Range into the flat `edges` array.
21    pub edge_range: Range<usize>,
22    /// Exports declared by this module.
23    pub exports: Vec<ExportSymbol>,
24    /// Re-exports from this module (export { x } from './y', export * from './z').
25    pub re_exports: Vec<ReExportEdge>,
26    /// Packed boolean flags (entry point, reachability, CJS).
27    pub(crate) flags: u8,
28}
29
30const FLAG_ENTRY_POINT: u8 = 1 << 0;
31const FLAG_REACHABLE: u8 = 1 << 1;
32const FLAG_RUNTIME_REACHABLE: u8 = 1 << 2;
33const FLAG_TEST_REACHABLE: u8 = 1 << 3;
34const FLAG_CJS_EXPORTS: u8 = 1 << 4;
35
36impl ModuleNode {
37    /// Whether this module is an entry point.
38    #[inline]
39    pub const fn is_entry_point(&self) -> bool {
40        self.flags & FLAG_ENTRY_POINT != 0
41    }
42
43    /// Whether this module is reachable from any entry point.
44    #[inline]
45    pub const fn is_reachable(&self) -> bool {
46        self.flags & FLAG_REACHABLE != 0
47    }
48
49    /// Whether this module is reachable from a runtime/application root.
50    #[inline]
51    pub const fn is_runtime_reachable(&self) -> bool {
52        self.flags & FLAG_RUNTIME_REACHABLE != 0
53    }
54
55    /// Whether this module is reachable from a test root.
56    #[inline]
57    pub const fn is_test_reachable(&self) -> bool {
58        self.flags & FLAG_TEST_REACHABLE != 0
59    }
60
61    /// Whether this module has CJS exports (module.exports / exports.*).
62    #[inline]
63    pub const fn has_cjs_exports(&self) -> bool {
64        self.flags & FLAG_CJS_EXPORTS != 0
65    }
66
67    /// Set whether this module is an entry point.
68    #[inline]
69    pub fn set_entry_point(&mut self, v: bool) {
70        if v {
71            self.flags |= FLAG_ENTRY_POINT;
72        } else {
73            self.flags &= !FLAG_ENTRY_POINT;
74        }
75    }
76
77    /// Set whether this module is reachable from any entry point.
78    #[inline]
79    pub fn set_reachable(&mut self, v: bool) {
80        if v {
81            self.flags |= FLAG_REACHABLE;
82        } else {
83            self.flags &= !FLAG_REACHABLE;
84        }
85    }
86
87    /// Set whether this module is reachable from a runtime/application root.
88    #[inline]
89    pub fn set_runtime_reachable(&mut self, v: bool) {
90        if v {
91            self.flags |= FLAG_RUNTIME_REACHABLE;
92        } else {
93            self.flags &= !FLAG_RUNTIME_REACHABLE;
94        }
95    }
96
97    /// Set whether this module is reachable from a test root.
98    #[inline]
99    pub fn set_test_reachable(&mut self, v: bool) {
100        if v {
101            self.flags |= FLAG_TEST_REACHABLE;
102        } else {
103            self.flags &= !FLAG_TEST_REACHABLE;
104        }
105    }
106
107    /// Set whether this module has CJS exports.
108    #[inline]
109    pub fn set_cjs_exports(&mut self, v: bool) {
110        if v {
111            self.flags |= FLAG_CJS_EXPORTS;
112        } else {
113            self.flags &= !FLAG_CJS_EXPORTS;
114        }
115    }
116
117    /// Build flags byte from individual booleans (used by graph construction).
118    #[inline]
119    pub(crate) fn flags_from(
120        is_entry_point: bool,
121        is_runtime_reachable: bool,
122        has_cjs_exports: bool,
123    ) -> u8 {
124        let mut f = 0u8;
125        if is_entry_point {
126            f |= FLAG_ENTRY_POINT;
127        }
128        if is_runtime_reachable {
129            f |= FLAG_RUNTIME_REACHABLE;
130        }
131        if has_cjs_exports {
132            f |= FLAG_CJS_EXPORTS;
133        }
134        f
135    }
136}
137
138/// A re-export edge, tracking which exports are forwarded from which module.
139#[derive(Debug)]
140pub struct ReExportEdge {
141    /// The module being re-exported from.
142    pub source_file: FileId,
143    /// The name imported from the source (or "*" for star re-exports).
144    pub imported_name: String,
145    /// The name exported from this module.
146    pub exported_name: String,
147    /// Whether this is a type-only re-export.
148    pub is_type_only: bool,
149    /// Source span of the re-export declaration on this module, used for
150    /// line-number reporting. `(0, 0)` for re-exports synthesized inside the
151    /// graph layer (e.g., `export *` chain propagation, namespace narrowing).
152    pub span: oxc_span::Span,
153}
154
155/// An export with reference tracking.
156#[derive(Debug)]
157pub struct ExportSymbol {
158    /// The exported name (named or default).
159    pub name: ExportName,
160    /// Whether this is a type-only export.
161    pub is_type_only: bool,
162    /// Whether this export is registered through a runtime side effect at module
163    /// load time (e.g. a Lit `@customElement('tag')` decorator or a
164    /// `customElements.define('tag', ClassRef)` call). The unused-export
165    /// detector treats this as an effective reference.
166    pub is_side_effect_used: bool,
167    /// Visibility tag from JSDoc/TSDoc comment (`@public`, `@internal`, `@alpha`, `@beta`).
168    /// Exports with any visibility tag are never reported as unused.
169    pub visibility: VisibilityTag,
170    /// Source span of the export declaration.
171    pub span: oxc_span::Span,
172    /// Which files reference this export.
173    pub references: Vec<SymbolReference>,
174    /// Members of this export (enum members, class members).
175    pub members: Vec<fallow_types::extract::MemberInfo>,
176}
177
178/// A reference to an export from another file.
179#[derive(Debug, Clone, Copy)]
180pub struct SymbolReference {
181    /// The file that references this export.
182    pub from_file: FileId,
183    /// How the export is referenced.
184    pub kind: ReferenceKind,
185    /// Byte span of the import statement in the referencing file.
186    /// Used by the LSP to locate references for Code Lens navigation.
187    pub import_span: oxc_span::Span,
188}
189
190/// How an export is referenced.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum ReferenceKind {
193    /// A named import (`import { foo }`).
194    NamedImport,
195    /// A default import (`import Foo`).
196    DefaultImport,
197    /// A namespace import (`import * as ns`).
198    NamespaceImport,
199    /// A re-export (`export { foo } from './bar'`).
200    ReExport,
201    /// A dynamic import (`import('./foo')`).
202    DynamicImport,
203    /// A side-effect import (`import './styles'`).
204    SideEffectImport,
205}
206
207#[cfg(target_pointer_width = "64")]
208const _: () = assert!(std::mem::size_of::<ExportSymbol>() == 88);
209#[cfg(target_pointer_width = "64")]
210const _: () = assert!(std::mem::size_of::<SymbolReference>() == 16);
211#[cfg(target_pointer_width = "64")]
212const _: () = assert!(std::mem::size_of::<ReExportEdge>() == 64);
213#[cfg(all(target_pointer_width = "64", unix))]
214const _: () = assert!(std::mem::size_of::<ModuleNode>() == 96);
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn reference_kind_equality() {
222        assert_eq!(ReferenceKind::NamedImport, ReferenceKind::NamedImport);
223        assert_ne!(ReferenceKind::NamedImport, ReferenceKind::DefaultImport);
224    }
225
226    #[test]
227    fn reference_kind_all_variants_are_distinct() {
228        let all = [
229            ReferenceKind::NamedImport,
230            ReferenceKind::DefaultImport,
231            ReferenceKind::NamespaceImport,
232            ReferenceKind::ReExport,
233            ReferenceKind::DynamicImport,
234            ReferenceKind::SideEffectImport,
235        ];
236        for (i, a) in all.iter().enumerate() {
237            for (j, b) in all.iter().enumerate() {
238                if i == j {
239                    assert_eq!(a, b);
240                } else {
241                    assert_ne!(a, b);
242                }
243            }
244        }
245    }
246
247    #[test]
248    fn reference_kind_copy() {
249        let original = ReferenceKind::NamespaceImport;
250        let copied = original;
251        assert_eq!(original, copied);
252    }
253
254    #[test]
255    fn reference_kind_debug_format() {
256        let kind = ReferenceKind::DynamicImport;
257        let debug_str = format!("{kind:?}");
258        assert_eq!(debug_str, "DynamicImport");
259    }
260
261    #[test]
262    fn symbol_reference_construction() {
263        let reference = SymbolReference {
264            from_file: FileId(42),
265            kind: ReferenceKind::NamedImport,
266            import_span: oxc_span::Span::new(10, 30),
267        };
268        assert_eq!(reference.from_file, FileId(42));
269        assert_eq!(reference.kind, ReferenceKind::NamedImport);
270        assert_eq!(reference.import_span.start, 10);
271        assert_eq!(reference.import_span.end, 30);
272    }
273
274    #[test]
275    fn symbol_reference_copy_preserves_all_fields() {
276        let reference = SymbolReference {
277            from_file: FileId(7),
278            kind: ReferenceKind::ReExport,
279            import_span: oxc_span::Span::new(5, 25),
280        };
281        let copied = reference;
282        assert_eq!(copied.from_file, reference.from_file);
283        assert_eq!(copied.kind, reference.kind);
284        assert_eq!(copied.import_span.start, reference.import_span.start);
285        assert_eq!(copied.import_span.end, reference.import_span.end);
286    }
287
288    #[test]
289    fn re_export_edge_construction() {
290        let edge = ReExportEdge {
291            source_file: FileId(3),
292            imported_name: "*".to_string(),
293            exported_name: "*".to_string(),
294            is_type_only: false,
295            span: oxc_span::Span::default(),
296        };
297        assert_eq!(edge.source_file, FileId(3));
298        assert_eq!(edge.imported_name, "*");
299        assert_eq!(edge.exported_name, "*");
300        assert!(!edge.is_type_only);
301    }
302
303    #[test]
304    fn re_export_edge_type_only() {
305        let edge = ReExportEdge {
306            source_file: FileId(1),
307            imported_name: "MyType".to_string(),
308            exported_name: "MyType".to_string(),
309            is_type_only: true,
310            span: oxc_span::Span::default(),
311        };
312        assert!(edge.is_type_only);
313    }
314
315    #[test]
316    fn re_export_edge_renamed() {
317        let edge = ReExportEdge {
318            source_file: FileId(2),
319            imported_name: "internal".to_string(),
320            exported_name: "public".to_string(),
321            is_type_only: false,
322            span: oxc_span::Span::default(),
323        };
324        assert_ne!(edge.imported_name, edge.exported_name);
325        assert_eq!(edge.imported_name, "internal");
326        assert_eq!(edge.exported_name, "public");
327    }
328
329    #[test]
330    fn export_symbol_named() {
331        let sym = ExportSymbol {
332            name: ExportName::Named("myFunction".to_string()),
333            is_type_only: false,
334            is_side_effect_used: false,
335            visibility: VisibilityTag::None,
336            span: oxc_span::Span::new(0, 50),
337            references: vec![],
338            members: vec![],
339        };
340        assert!(matches!(sym.name, ExportName::Named(ref n) if n == "myFunction"));
341        assert!(!sym.is_type_only);
342        assert_eq!(sym.visibility, VisibilityTag::None);
343    }
344
345    #[test]
346    fn export_symbol_default() {
347        let sym = ExportSymbol {
348            name: ExportName::Default,
349            is_type_only: false,
350            is_side_effect_used: false,
351            visibility: VisibilityTag::None,
352            span: oxc_span::Span::new(0, 20),
353            references: vec![],
354            members: vec![],
355        };
356        assert!(matches!(sym.name, ExportName::Default));
357    }
358
359    #[test]
360    fn export_symbol_public_tag() {
361        let sym = ExportSymbol {
362            name: ExportName::Named("api".to_string()),
363            is_type_only: false,
364            is_side_effect_used: false,
365            visibility: VisibilityTag::Public,
366            span: oxc_span::Span::new(0, 10),
367            references: vec![],
368            members: vec![],
369        };
370        assert_eq!(sym.visibility, VisibilityTag::Public);
371    }
372
373    #[test]
374    fn export_symbol_type_only() {
375        let sym = ExportSymbol {
376            name: ExportName::Named("MyInterface".to_string()),
377            is_type_only: true,
378            is_side_effect_used: false,
379            visibility: VisibilityTag::None,
380            span: oxc_span::Span::new(0, 30),
381            references: vec![],
382            members: vec![],
383        };
384        assert!(sym.is_type_only);
385    }
386
387    #[test]
388    fn export_symbol_with_references() {
389        let sym = ExportSymbol {
390            name: ExportName::Named("helper".to_string()),
391            is_type_only: false,
392            is_side_effect_used: false,
393            visibility: VisibilityTag::None,
394            span: oxc_span::Span::new(0, 20),
395            references: vec![
396                SymbolReference {
397                    from_file: FileId(1),
398                    kind: ReferenceKind::NamedImport,
399                    import_span: oxc_span::Span::new(0, 10),
400                },
401                SymbolReference {
402                    from_file: FileId(2),
403                    kind: ReferenceKind::ReExport,
404                    import_span: oxc_span::Span::new(5, 15),
405                },
406            ],
407            members: vec![],
408        };
409        assert_eq!(sym.references.len(), 2);
410        assert_eq!(sym.references[0].from_file, FileId(1));
411        assert_eq!(sym.references[1].kind, ReferenceKind::ReExport);
412    }
413
414    #[test]
415    fn module_node_construction() {
416        let mut node = ModuleNode {
417            file_id: FileId(0),
418            path: PathBuf::from("/project/src/index.ts"),
419            edge_range: 0..5,
420            exports: vec![],
421            re_exports: vec![],
422            flags: ModuleNode::flags_from(true, true, false),
423        };
424        node.set_reachable(true);
425        assert_eq!(node.file_id, FileId(0));
426        assert!(node.is_entry_point());
427        assert!(node.is_reachable());
428        assert!(node.is_runtime_reachable());
429        assert!(!node.is_test_reachable());
430        assert!(!node.has_cjs_exports());
431        assert_eq!(node.edge_range, 0..5);
432    }
433
434    #[test]
435    fn module_node_non_entry_unreachable() {
436        let node = ModuleNode {
437            file_id: FileId(5),
438            path: PathBuf::from("/project/src/orphan.ts"),
439            edge_range: 0..0,
440            exports: vec![],
441            re_exports: vec![],
442            flags: ModuleNode::flags_from(false, false, false),
443        };
444        assert!(!node.is_entry_point());
445        assert!(!node.is_reachable());
446        assert!(!node.is_runtime_reachable());
447        assert!(!node.is_test_reachable());
448        assert!(node.edge_range.is_empty());
449    }
450
451    #[test]
452    fn module_node_cjs_exports() {
453        let mut node = ModuleNode {
454            file_id: FileId(2),
455            path: PathBuf::from("/project/lib/legacy.js"),
456            edge_range: 3..7,
457            exports: vec![],
458            re_exports: vec![],
459            flags: ModuleNode::flags_from(false, true, true),
460        };
461        node.set_reachable(true);
462        assert!(node.has_cjs_exports());
463        assert!(node.is_runtime_reachable());
464        assert_eq!(node.edge_range.len(), 4);
465    }
466
467    #[test]
468    fn module_node_with_exports_and_re_exports() {
469        let node = ModuleNode {
470            file_id: FileId(1),
471            path: PathBuf::from("/project/src/barrel.ts"),
472            edge_range: 0..3,
473            exports: vec![ExportSymbol {
474                name: ExportName::Named("localFn".to_string()),
475                is_type_only: false,
476                is_side_effect_used: false,
477                visibility: VisibilityTag::None,
478                span: oxc_span::Span::new(0, 20),
479                references: vec![],
480                members: vec![],
481            }],
482            re_exports: vec![ReExportEdge {
483                source_file: FileId(2),
484                imported_name: "*".to_string(),
485                exported_name: "*".to_string(),
486                is_type_only: false,
487                span: oxc_span::Span::default(),
488            }],
489            flags: ModuleNode::flags_from(false, true, false),
490        };
491        assert_eq!(node.exports.len(), 1);
492        assert_eq!(node.re_exports.len(), 1);
493        assert_eq!(node.re_exports[0].source_file, FileId(2));
494    }
495}