Skip to main content

fallow_types/
extract.rs

1//! Module extraction types: exports, imports, re-exports, members, and parse results.
2
3use oxc_span::Span;
4
5use crate::discover::FileId;
6use crate::suppress::Suppression;
7
8/// Extracted module information from a single file.
9#[derive(Debug, Clone)]
10pub struct ModuleInfo {
11    /// Unique identifier for this file.
12    pub file_id: FileId,
13    /// All export declarations in this module.
14    pub exports: Vec<ExportInfo>,
15    /// All import declarations in this module.
16    pub imports: Vec<ImportInfo>,
17    /// All re-export declarations (e.g., `export { foo } from './bar'`).
18    pub re_exports: Vec<ReExportInfo>,
19    /// All dynamic `import()` calls with string literal sources.
20    pub dynamic_imports: Vec<DynamicImportInfo>,
21    /// Dynamic import patterns from template literals, string concat, or `import.meta.glob`.
22    pub dynamic_import_patterns: Vec<DynamicImportPattern>,
23    /// All `require()` calls.
24    pub require_calls: Vec<RequireCallInfo>,
25    /// Static member access expressions (e.g., `Status.Active`).
26    pub member_accesses: Vec<MemberAccess>,
27    /// Identifiers used in "all members consumed" patterns
28    /// (Object.values, Object.keys, Object.entries, Object.getOwnPropertyNames, for..in, spread, computed dynamic access).
29    pub whole_object_uses: Vec<String>,
30    /// Whether this module uses `CommonJS` exports (`module.exports` or `exports.*`).
31    pub has_cjs_exports: bool,
32    /// xxh3 hash of the file content for incremental caching.
33    pub content_hash: u64,
34    /// Inline suppression directives parsed from comments.
35    pub suppressions: Vec<Suppression>,
36    /// Local names of import bindings that are never referenced in this file.
37    /// Populated via `oxc_semantic` scope analysis. Used at graph-build time
38    /// to skip adding references for imports whose binding is never read,
39    /// improving unused-export detection precision.
40    pub unused_import_bindings: Vec<String>,
41    /// Pre-computed byte offsets where each line starts, for O(log N) byte-to-line/col conversion.
42    /// Entry `i` is the byte offset of the start of line `i` (0-indexed).
43    /// Example: for "abc\ndef\n", `line_offsets` = \[0, 4\].
44    pub line_offsets: Vec<u32>,
45    /// Per-function complexity metrics computed during AST traversal.
46    /// Used by the `fallow health` subcommand to report high-complexity functions.
47    pub complexity: Vec<FunctionComplexity>,
48}
49
50/// Compute a table of line-start byte offsets from source text.
51///
52/// The returned vec contains one entry per line: `line_offsets[i]` is the byte
53/// offset where line `i` starts (0-indexed). The first entry is always `0`.
54#[must_use]
55pub fn compute_line_offsets(source: &str) -> Vec<u32> {
56    let mut offsets = vec![0u32];
57    for (i, byte) in source.bytes().enumerate() {
58        if byte == b'\n' {
59            offsets.push((i + 1) as u32);
60        }
61    }
62    offsets
63}
64
65/// Convert a byte offset to a 1-based line number and 0-based byte column
66/// using a pre-computed line offset table (from [`compute_line_offsets`]).
67///
68/// Uses binary search for O(log L) lookup where L is the number of lines.
69#[must_use]
70pub fn byte_offset_to_line_col(line_offsets: &[u32], byte_offset: u32) -> (u32, u32) {
71    // Binary search: find the last line whose start is <= byte_offset
72    let line_idx = match line_offsets.binary_search(&byte_offset) {
73        Ok(idx) => idx,
74        Err(idx) => idx.saturating_sub(1),
75    };
76    let line = line_idx as u32 + 1; // 1-based
77    let col = byte_offset - line_offsets[line_idx];
78    (line, col)
79}
80
81/// Complexity metrics for a single function/method/arrow.
82#[derive(Debug, Clone, serde::Serialize, bincode::Encode, bincode::Decode)]
83pub struct FunctionComplexity {
84    /// Function name (or `"<anonymous>"` for unnamed functions/arrows).
85    pub name: String,
86    /// 1-based line number where the function starts.
87    pub line: u32,
88    /// 0-based byte column where the function starts.
89    pub col: u32,
90    /// `McCabe` cyclomatic complexity (1 + decision points).
91    pub cyclomatic: u16,
92    /// `SonarSource` cognitive complexity (structural + nesting penalty).
93    pub cognitive: u16,
94    /// Number of lines in the function body.
95    pub line_count: u32,
96}
97
98/// A dynamic import with a pattern that can be partially resolved (e.g., template literals).
99#[derive(Debug, Clone)]
100pub struct DynamicImportPattern {
101    /// Static prefix of the import path (e.g., "./locales/"). May contain glob characters.
102    pub prefix: String,
103    /// Static suffix of the import path (e.g., ".json"), if any.
104    pub suffix: Option<String>,
105    /// Source span in the original file.
106    pub span: Span,
107}
108
109/// An export declaration.
110#[derive(Debug, Clone, serde::Serialize)]
111pub struct ExportInfo {
112    /// The exported name (named or default).
113    pub name: ExportName,
114    /// The local binding name, if different from the exported name.
115    pub local_name: Option<String>,
116    /// Whether this is a type-only export (`export type`).
117    pub is_type_only: bool,
118    /// Whether this export has a `@public` JSDoc/TSDoc tag.
119    /// Exports marked `@public` are never reported as unused — they are
120    /// assumed to be consumed by external consumers (library API surface).
121    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
122    pub is_public: bool,
123    /// Source span of the export declaration.
124    #[serde(serialize_with = "serialize_span")]
125    pub span: Span,
126    /// Members of this export (for enums and classes).
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub members: Vec<MemberInfo>,
129}
130
131/// A member of an enum or class.
132#[derive(Debug, Clone, serde::Serialize)]
133pub struct MemberInfo {
134    /// Member name.
135    pub name: String,
136    /// Whether this is an enum member, class method, or class property.
137    pub kind: MemberKind,
138    /// Source span of the member declaration.
139    #[serde(serialize_with = "serialize_span")]
140    pub span: Span,
141    /// Whether this member has decorators (e.g., `@Column()`, `@Inject()`).
142    /// Decorated members are used by frameworks at runtime and should not be
143    /// flagged as unused class members.
144    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
145    pub has_decorator: bool,
146}
147
148/// The kind of member.
149#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, bincode::Encode, bincode::Decode)]
150#[serde(rename_all = "snake_case")]
151pub enum MemberKind {
152    /// A TypeScript enum member.
153    EnumMember,
154    /// A class method.
155    ClassMethod,
156    /// A class property.
157    ClassProperty,
158}
159
160/// A static member access expression (e.g., `Status.Active`, `MyClass.create()`).
161#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bincode::Encode, bincode::Decode)]
162pub struct MemberAccess {
163    /// The identifier being accessed (the import name).
164    pub object: String,
165    /// The member being accessed.
166    pub member: String,
167}
168
169#[expect(clippy::trivially_copy_pass_by_ref)] // serde serialize_with requires &T
170fn serialize_span<S: serde::Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
171    use serde::ser::SerializeMap;
172    let mut map = serializer.serialize_map(Some(2))?;
173    map.serialize_entry("start", &span.start)?;
174    map.serialize_entry("end", &span.end)?;
175    map.end()
176}
177
178/// Export identifier.
179#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
180pub enum ExportName {
181    /// A named export (e.g., `export const foo`).
182    Named(String),
183    /// The default export.
184    Default,
185}
186
187impl ExportName {
188    /// Compare against a string without allocating (avoids `to_string()`).
189    #[must_use]
190    pub fn matches_str(&self, s: &str) -> bool {
191        match self {
192            Self::Named(n) => n == s,
193            Self::Default => s == "default",
194        }
195    }
196}
197
198impl std::fmt::Display for ExportName {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        match self {
201            Self::Named(n) => write!(f, "{n}"),
202            Self::Default => write!(f, "default"),
203        }
204    }
205}
206
207/// An import declaration.
208#[derive(Debug, Clone)]
209pub struct ImportInfo {
210    /// The import specifier (e.g., `./utils` or `react`).
211    pub source: String,
212    /// How the symbol is imported (named, default, namespace, or side-effect).
213    pub imported_name: ImportedName,
214    /// The local binding name in the importing module.
215    pub local_name: String,
216    /// Whether this is a type-only import (`import type`).
217    pub is_type_only: bool,
218    /// Source span of the import declaration.
219    pub span: Span,
220    /// Span of the source string literal (e.g., the `'./utils'` in `import { foo } from './utils'`).
221    /// Used by the LSP to highlight just the specifier in diagnostics.
222    pub source_span: Span,
223}
224
225/// How a symbol is imported.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum ImportedName {
228    /// A named import (e.g., `import { foo }`).
229    Named(String),
230    /// A default import (e.g., `import React`).
231    Default,
232    /// A namespace import (e.g., `import * as utils`).
233    Namespace,
234    /// A side-effect import (e.g., `import './styles.css'`).
235    SideEffect,
236}
237
238// Size assertions to prevent memory regressions in hot-path types.
239// These types are stored in Vecs inside `ModuleInfo` (one per file) and are
240// iterated during graph construction and analysis. Keeping them compact
241// improves cache locality on large projects with thousands of files.
242#[cfg(target_pointer_width = "64")]
243const _: () = assert!(std::mem::size_of::<ExportInfo>() == 88);
244#[cfg(target_pointer_width = "64")]
245const _: () = assert!(std::mem::size_of::<ImportInfo>() == 96);
246#[cfg(target_pointer_width = "64")]
247const _: () = assert!(std::mem::size_of::<ExportName>() == 24);
248#[cfg(target_pointer_width = "64")]
249const _: () = assert!(std::mem::size_of::<ImportedName>() == 24);
250#[cfg(target_pointer_width = "64")]
251const _: () = assert!(std::mem::size_of::<MemberAccess>() == 48);
252// `ModuleInfo` is the per-file extraction result — stored in a Vec during parallel parsing.
253#[cfg(target_pointer_width = "64")]
254const _: () = assert!(std::mem::size_of::<ModuleInfo>() == 304);
255
256/// A re-export declaration.
257#[derive(Debug, Clone)]
258pub struct ReExportInfo {
259    /// The module being re-exported from.
260    pub source: String,
261    /// The name imported from the source module (or `*` for star re-exports).
262    pub imported_name: String,
263    /// The name exported from this module.
264    pub exported_name: String,
265    /// Whether this is a type-only re-export.
266    pub is_type_only: bool,
267}
268
269/// A dynamic `import()` call.
270#[derive(Debug, Clone)]
271pub struct DynamicImportInfo {
272    /// The import specifier.
273    pub source: String,
274    /// Source span of the `import()` expression.
275    pub span: Span,
276    /// Names destructured from the dynamic import result.
277    /// Non-empty means `const { a, b } = await import(...)` -> Named imports.
278    /// Empty means simple `import(...)` or `const x = await import(...)` -> Namespace.
279    pub destructured_names: Vec<String>,
280    /// The local variable name for `const x = await import(...)`.
281    /// Used for namespace import narrowing via member access tracking.
282    pub local_name: Option<String>,
283}
284
285/// A `require()` call.
286#[derive(Debug, Clone)]
287pub struct RequireCallInfo {
288    /// The require specifier.
289    pub source: String,
290    /// Source span of the `require()` call.
291    pub span: Span,
292    /// Names destructured from the `require()` result.
293    /// Non-empty means `const { a, b } = require(...)` -> Named imports.
294    /// Empty means simple `require(...)` or `const x = require(...)` -> Namespace.
295    pub destructured_names: Vec<String>,
296    /// The local variable name for `const x = require(...)`.
297    /// Used for namespace import narrowing via member access tracking.
298    pub local_name: Option<String>,
299}
300
301/// Result of parsing all files, including incremental cache statistics.
302pub struct ParseResult {
303    /// Extracted module information for all successfully parsed files.
304    pub modules: Vec<ModuleInfo>,
305    /// Number of files whose parse results were loaded from cache (unchanged).
306    pub cache_hits: usize,
307    /// Number of files that required a full parse (new or changed).
308    pub cache_misses: usize,
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    // ── compute_line_offsets ──────────────────────────────────────────
316
317    #[test]
318    fn line_offsets_empty_string() {
319        assert_eq!(compute_line_offsets(""), vec![0]);
320    }
321
322    #[test]
323    fn line_offsets_single_line_no_newline() {
324        assert_eq!(compute_line_offsets("hello"), vec![0]);
325    }
326
327    #[test]
328    fn line_offsets_single_line_with_newline() {
329        // "hello\n" => line 0 starts at 0, line 1 starts at 6
330        assert_eq!(compute_line_offsets("hello\n"), vec![0, 6]);
331    }
332
333    #[test]
334    fn line_offsets_multiple_lines() {
335        // "abc\ndef\nghi"
336        // line 0: offset 0 ("abc")
337        // line 1: offset 4 ("def")
338        // line 2: offset 8 ("ghi")
339        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
340    }
341
342    #[test]
343    fn line_offsets_trailing_newline() {
344        // "abc\ndef\n"
345        // line 0: offset 0, line 1: offset 4, line 2: offset 8 (empty line after trailing \n)
346        assert_eq!(compute_line_offsets("abc\ndef\n"), vec![0, 4, 8]);
347    }
348
349    #[test]
350    fn line_offsets_consecutive_newlines() {
351        // "\n\n\n" = 3 newlines => 4 lines
352        assert_eq!(compute_line_offsets("\n\n\n"), vec![0, 1, 2, 3]);
353    }
354
355    #[test]
356    fn line_offsets_multibyte_utf8() {
357        // "á\n" => 'á' is 2 bytes (0xC3 0xA1), '\n' at byte 2 => next line at byte 3
358        assert_eq!(compute_line_offsets("á\n"), vec![0, 3]);
359    }
360
361    // ── byte_offset_to_line_col ──────────────────────────────────────
362
363    #[test]
364    fn line_col_offset_zero() {
365        let offsets = compute_line_offsets("abc\ndef\nghi");
366        let (line, col) = byte_offset_to_line_col(&offsets, 0);
367        assert_eq!((line, col), (1, 0)); // line 1, col 0
368    }
369
370    #[test]
371    fn line_col_middle_of_first_line() {
372        let offsets = compute_line_offsets("abc\ndef\nghi");
373        let (line, col) = byte_offset_to_line_col(&offsets, 2);
374        assert_eq!((line, col), (1, 2)); // 'c' in "abc"
375    }
376
377    #[test]
378    fn line_col_start_of_second_line() {
379        let offsets = compute_line_offsets("abc\ndef\nghi");
380        // byte 4 = start of "def"
381        let (line, col) = byte_offset_to_line_col(&offsets, 4);
382        assert_eq!((line, col), (2, 0));
383    }
384
385    #[test]
386    fn line_col_middle_of_second_line() {
387        let offsets = compute_line_offsets("abc\ndef\nghi");
388        // byte 5 = 'e' in "def"
389        let (line, col) = byte_offset_to_line_col(&offsets, 5);
390        assert_eq!((line, col), (2, 1));
391    }
392
393    #[test]
394    fn line_col_start_of_third_line() {
395        let offsets = compute_line_offsets("abc\ndef\nghi");
396        // byte 8 = start of "ghi"
397        let (line, col) = byte_offset_to_line_col(&offsets, 8);
398        assert_eq!((line, col), (3, 0));
399    }
400
401    #[test]
402    fn line_col_end_of_file() {
403        let offsets = compute_line_offsets("abc\ndef\nghi");
404        // byte 10 = 'i' (last char)
405        let (line, col) = byte_offset_to_line_col(&offsets, 10);
406        assert_eq!((line, col), (3, 2));
407    }
408
409    #[test]
410    fn line_col_single_line() {
411        let offsets = compute_line_offsets("hello");
412        let (line, col) = byte_offset_to_line_col(&offsets, 3);
413        assert_eq!((line, col), (1, 3));
414    }
415
416    #[test]
417    fn line_col_at_newline_byte() {
418        let offsets = compute_line_offsets("abc\ndef");
419        // byte 3 = the '\n' character itself, still part of line 1
420        let (line, col) = byte_offset_to_line_col(&offsets, 3);
421        assert_eq!((line, col), (1, 3));
422    }
423
424    // ── ExportName ───────────────────────────────────────────────────
425
426    #[test]
427    fn export_name_matches_str_named() {
428        let name = ExportName::Named("foo".to_string());
429        assert!(name.matches_str("foo"));
430        assert!(!name.matches_str("bar"));
431        assert!(!name.matches_str("default"));
432    }
433
434    #[test]
435    fn export_name_matches_str_default() {
436        let name = ExportName::Default;
437        assert!(name.matches_str("default"));
438        assert!(!name.matches_str("foo"));
439    }
440
441    #[test]
442    fn export_name_display_named() {
443        let name = ExportName::Named("myExport".to_string());
444        assert_eq!(name.to_string(), "myExport");
445    }
446
447    #[test]
448    fn export_name_display_default() {
449        let name = ExportName::Default;
450        assert_eq!(name.to_string(), "default");
451    }
452}