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