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    /// Human-authored reason on `@expected-unused -- <reason>`, when present.
171    pub expected_unused_reason: Option<String>,
172    /// Source span of the export declaration.
173    pub span: oxc_span::Span,
174    /// Which files reference this export.
175    pub references: Vec<SymbolReference>,
176    /// Members of this export (enum members, class members).
177    pub members: Vec<fallow_types::extract::MemberInfo>,
178}
179
180/// A reference to an export from another file.
181#[derive(Debug, Clone, Copy)]
182pub struct SymbolReference {
183    /// The file that references this export.
184    pub from_file: FileId,
185    /// How the export is referenced.
186    pub kind: ReferenceKind,
187    /// Byte span of the import statement in the referencing file.
188    /// Used by the LSP to locate references for Code Lens navigation.
189    pub import_span: oxc_span::Span,
190}
191
192/// How an export is referenced.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum ReferenceKind {
195    /// A named import (`import { foo }`).
196    NamedImport,
197    /// A default import (`import Foo`).
198    DefaultImport,
199    /// A namespace import (`import * as ns`).
200    NamespaceImport,
201    /// A re-export (`export { foo } from './bar'`).
202    ReExport,
203    /// A dynamic import (`import('./foo')`).
204    DynamicImport,
205    /// A side-effect import (`import './styles'`).
206    SideEffectImport,
207}
208
209#[cfg(target_pointer_width = "64")]
210const _: () = assert!(std::mem::size_of::<ExportSymbol>() == 112);
211#[cfg(target_pointer_width = "64")]
212const _: () = assert!(std::mem::size_of::<SymbolReference>() == 16);
213#[cfg(target_pointer_width = "64")]
214const _: () = assert!(std::mem::size_of::<ReExportEdge>() == 64);
215#[cfg(all(target_pointer_width = "64", unix))]
216const _: () = assert!(std::mem::size_of::<ModuleNode>() == 96);
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn reference_kind_equality() {
224        assert_eq!(ReferenceKind::NamedImport, ReferenceKind::NamedImport);
225        assert_ne!(ReferenceKind::NamedImport, ReferenceKind::DefaultImport);
226    }
227
228    #[test]
229    fn reference_kind_all_variants_are_distinct() {
230        let all = [
231            ReferenceKind::NamedImport,
232            ReferenceKind::DefaultImport,
233            ReferenceKind::NamespaceImport,
234            ReferenceKind::ReExport,
235            ReferenceKind::DynamicImport,
236            ReferenceKind::SideEffectImport,
237        ];
238        for (i, a) in all.iter().enumerate() {
239            for (j, b) in all.iter().enumerate() {
240                if i == j {
241                    assert_eq!(a, b);
242                } else {
243                    assert_ne!(a, b);
244                }
245            }
246        }
247    }
248
249    #[test]
250    fn reference_kind_copy() {
251        let original = ReferenceKind::NamespaceImport;
252        let copied = original;
253        assert_eq!(original, copied);
254    }
255
256    #[test]
257    fn reference_kind_debug_format() {
258        let kind = ReferenceKind::DynamicImport;
259        let debug_str = format!("{kind:?}");
260        assert_eq!(debug_str, "DynamicImport");
261    }
262
263    #[test]
264    fn symbol_reference_construction() {
265        let reference = SymbolReference {
266            from_file: FileId(42),
267            kind: ReferenceKind::NamedImport,
268            import_span: oxc_span::Span::new(10, 30),
269        };
270        assert_eq!(reference.from_file, FileId(42));
271        assert_eq!(reference.kind, ReferenceKind::NamedImport);
272        assert_eq!(reference.import_span.start, 10);
273        assert_eq!(reference.import_span.end, 30);
274    }
275
276    #[test]
277    fn symbol_reference_copy_preserves_all_fields() {
278        let reference = SymbolReference {
279            from_file: FileId(7),
280            kind: ReferenceKind::ReExport,
281            import_span: oxc_span::Span::new(5, 25),
282        };
283        let copied = reference;
284        assert_eq!(copied.from_file, reference.from_file);
285        assert_eq!(copied.kind, reference.kind);
286        assert_eq!(copied.import_span.start, reference.import_span.start);
287        assert_eq!(copied.import_span.end, reference.import_span.end);
288    }
289
290    #[test]
291    fn re_export_edge_construction() {
292        let edge = ReExportEdge {
293            source_file: FileId(3),
294            imported_name: "*".to_string(),
295            exported_name: "*".to_string(),
296            is_type_only: false,
297            span: oxc_span::Span::default(),
298        };
299        assert_eq!(edge.source_file, FileId(3));
300        assert_eq!(edge.imported_name, "*");
301        assert_eq!(edge.exported_name, "*");
302        assert!(!edge.is_type_only);
303    }
304
305    #[test]
306    fn re_export_edge_type_only() {
307        let edge = ReExportEdge {
308            source_file: FileId(1),
309            imported_name: "MyType".to_string(),
310            exported_name: "MyType".to_string(),
311            is_type_only: true,
312            span: oxc_span::Span::default(),
313        };
314        assert!(edge.is_type_only);
315    }
316
317    #[test]
318    fn re_export_edge_renamed() {
319        let edge = ReExportEdge {
320            source_file: FileId(2),
321            imported_name: "internal".to_string(),
322            exported_name: "public".to_string(),
323            is_type_only: false,
324            span: oxc_span::Span::default(),
325        };
326        assert_ne!(edge.imported_name, edge.exported_name);
327        assert_eq!(edge.imported_name, "internal");
328        assert_eq!(edge.exported_name, "public");
329    }
330
331    #[test]
332    fn export_symbol_named() {
333        let sym = ExportSymbol {
334            name: ExportName::Named("myFunction".to_string()),
335            is_type_only: false,
336            is_side_effect_used: false,
337            visibility: VisibilityTag::None,
338            expected_unused_reason: None,
339            span: oxc_span::Span::new(0, 50),
340            references: vec![],
341            members: vec![],
342        };
343        assert!(matches!(sym.name, ExportName::Named(ref n) if n == "myFunction"));
344        assert!(!sym.is_type_only);
345        assert_eq!(sym.visibility, VisibilityTag::None);
346    }
347
348    #[test]
349    fn export_symbol_default() {
350        let sym = ExportSymbol {
351            name: ExportName::Default,
352            is_type_only: false,
353            is_side_effect_used: false,
354            visibility: VisibilityTag::None,
355            expected_unused_reason: None,
356            span: oxc_span::Span::new(0, 20),
357            references: vec![],
358            members: vec![],
359        };
360        assert!(matches!(sym.name, ExportName::Default));
361    }
362
363    #[test]
364    fn export_symbol_public_tag() {
365        let sym = ExportSymbol {
366            name: ExportName::Named("api".to_string()),
367            is_type_only: false,
368            is_side_effect_used: false,
369            visibility: VisibilityTag::Public,
370            expected_unused_reason: None,
371            span: oxc_span::Span::new(0, 10),
372            references: vec![],
373            members: vec![],
374        };
375        assert_eq!(sym.visibility, VisibilityTag::Public);
376    }
377
378    #[test]
379    fn export_symbol_type_only() {
380        let sym = ExportSymbol {
381            name: ExportName::Named("MyInterface".to_string()),
382            is_type_only: true,
383            is_side_effect_used: false,
384            visibility: VisibilityTag::None,
385            expected_unused_reason: None,
386            span: oxc_span::Span::new(0, 30),
387            references: vec![],
388            members: vec![],
389        };
390        assert!(sym.is_type_only);
391    }
392
393    #[test]
394    fn export_symbol_with_references() {
395        let sym = ExportSymbol {
396            name: ExportName::Named("helper".to_string()),
397            is_type_only: false,
398            is_side_effect_used: false,
399            visibility: VisibilityTag::None,
400            expected_unused_reason: None,
401            span: oxc_span::Span::new(0, 20),
402            references: vec![
403                SymbolReference {
404                    from_file: FileId(1),
405                    kind: ReferenceKind::NamedImport,
406                    import_span: oxc_span::Span::new(0, 10),
407                },
408                SymbolReference {
409                    from_file: FileId(2),
410                    kind: ReferenceKind::ReExport,
411                    import_span: oxc_span::Span::new(5, 15),
412                },
413            ],
414            members: vec![],
415        };
416        assert_eq!(sym.references.len(), 2);
417        assert_eq!(sym.references[0].from_file, FileId(1));
418        assert_eq!(sym.references[1].kind, ReferenceKind::ReExport);
419    }
420
421    #[test]
422    fn module_node_construction() {
423        let mut node = ModuleNode {
424            file_id: FileId(0),
425            path: PathBuf::from("/project/src/index.ts"),
426            edge_range: 0..5,
427            exports: vec![],
428            re_exports: vec![],
429            flags: ModuleNode::flags_from(true, true, false),
430        };
431        node.set_reachable(true);
432        assert_eq!(node.file_id, FileId(0));
433        assert!(node.is_entry_point());
434        assert!(node.is_reachable());
435        assert!(node.is_runtime_reachable());
436        assert!(!node.is_test_reachable());
437        assert!(!node.has_cjs_exports());
438        assert_eq!(node.edge_range, 0..5);
439    }
440
441    #[test]
442    fn module_node_non_entry_unreachable() {
443        let node = ModuleNode {
444            file_id: FileId(5),
445            path: PathBuf::from("/project/src/orphan.ts"),
446            edge_range: 0..0,
447            exports: vec![],
448            re_exports: vec![],
449            flags: ModuleNode::flags_from(false, false, false),
450        };
451        assert!(!node.is_entry_point());
452        assert!(!node.is_reachable());
453        assert!(!node.is_runtime_reachable());
454        assert!(!node.is_test_reachable());
455        assert!(node.edge_range.is_empty());
456    }
457
458    #[test]
459    fn module_node_cjs_exports() {
460        let mut node = ModuleNode {
461            file_id: FileId(2),
462            path: PathBuf::from("/project/lib/legacy.js"),
463            edge_range: 3..7,
464            exports: vec![],
465            re_exports: vec![],
466            flags: ModuleNode::flags_from(false, true, true),
467        };
468        node.set_reachable(true);
469        assert!(node.has_cjs_exports());
470        assert!(node.is_runtime_reachable());
471        assert_eq!(node.edge_range.len(), 4);
472    }
473
474    #[test]
475    fn module_node_with_exports_and_re_exports() {
476        let node = ModuleNode {
477            file_id: FileId(1),
478            path: PathBuf::from("/project/src/barrel.ts"),
479            edge_range: 0..3,
480            exports: vec![ExportSymbol {
481                name: ExportName::Named("localFn".to_string()),
482                is_type_only: false,
483                is_side_effect_used: false,
484                visibility: VisibilityTag::None,
485                expected_unused_reason: None,
486                span: oxc_span::Span::new(0, 20),
487                references: vec![],
488                members: vec![],
489            }],
490            re_exports: vec![ReExportEdge {
491                source_file: FileId(2),
492                imported_name: "*".to_string(),
493                exported_name: "*".to_string(),
494                is_type_only: false,
495                span: oxc_span::Span::default(),
496            }],
497            flags: ModuleNode::flags_from(false, true, false),
498        };
499        assert_eq!(node.exports.len(), 1);
500        assert_eq!(node.re_exports.len(), 1);
501        assert_eq!(node.re_exports[0].source_file, FileId(2));
502    }
503}