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