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