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    /// Local import bindings that are referenced from TypeScript type positions.
42    /// Used to distinguish value-namespace and type-namespace references when a
43    /// module exports both `const X` and `type X`.
44    pub type_referenced_import_bindings: Vec<String>,
45    /// Local import bindings that are referenced from runtime/value positions.
46    /// Used alongside `type_referenced_import_bindings` for TS namespace-split
47    /// exports that share the same name.
48    pub value_referenced_import_bindings: Vec<String>,
49    /// Pre-computed byte offsets where each line starts, for O(log N) byte-to-line/col conversion.
50    /// Entry `i` is the byte offset of the start of line `i` (0-indexed).
51    /// Example: for "abc\ndef\n", `line_offsets` = \[0, 4\].
52    pub line_offsets: Vec<u32>,
53    /// Per-function complexity metrics computed during AST traversal.
54    /// Used by the `fallow health` subcommand to report high-complexity functions.
55    pub complexity: Vec<FunctionComplexity>,
56    /// Feature flag use sites detected during AST traversal.
57    /// Used by the `fallow flags` subcommand to report feature flag patterns.
58    pub flag_uses: Vec<FlagUse>,
59    /// Heritage metadata for exported classes that declare `implements`.
60    /// Used to scope `usedClassMembers` rules during analysis.
61    pub class_heritage: Vec<ClassHeritageInfo>,
62}
63
64/// Compute a table of line-start byte offsets from source text.
65///
66/// The returned vec contains one entry per line: `line_offsets[i]` is the byte
67/// offset where line `i` starts (0-indexed). The first entry is always `0`.
68///
69/// # Examples
70///
71/// ```
72/// use fallow_types::extract::compute_line_offsets;
73///
74/// let offsets = compute_line_offsets("abc\ndef\nghi");
75/// assert_eq!(offsets, vec![0, 4, 8]);
76/// ```
77#[must_use]
78#[expect(
79    clippy::cast_possible_truncation,
80    reason = "source files are practically < 4GB"
81)]
82pub fn compute_line_offsets(source: &str) -> Vec<u32> {
83    let mut offsets = vec![0u32];
84    for (i, byte) in source.bytes().enumerate() {
85        if byte == b'\n' {
86            debug_assert!(
87                u32::try_from(i + 1).is_ok(),
88                "source file exceeds u32::MAX bytes — line offsets would overflow"
89            );
90            offsets.push((i + 1) as u32);
91        }
92    }
93    offsets
94}
95
96/// Convert a byte offset to a 1-based line number and 0-based byte column
97/// using a pre-computed line offset table (from [`compute_line_offsets`]).
98///
99/// Uses binary search for O(log L) lookup where L is the number of lines.
100///
101/// # Examples
102///
103/// ```
104/// use fallow_types::extract::{compute_line_offsets, byte_offset_to_line_col};
105///
106/// let offsets = compute_line_offsets("abc\ndef\nghi");
107/// assert_eq!(byte_offset_to_line_col(&offsets, 0), (1, 0)); // 'a' on line 1
108/// assert_eq!(byte_offset_to_line_col(&offsets, 4), (2, 0)); // 'd' on line 2
109/// assert_eq!(byte_offset_to_line_col(&offsets, 9), (3, 1)); // 'h' on line 3
110/// ```
111#[must_use]
112#[expect(
113    clippy::cast_possible_truncation,
114    reason = "line count is bounded by source size"
115)]
116pub fn byte_offset_to_line_col(line_offsets: &[u32], byte_offset: u32) -> (u32, u32) {
117    // Binary search: find the last line whose start is <= byte_offset
118    let line_idx = match line_offsets.binary_search(&byte_offset) {
119        Ok(idx) => idx,
120        Err(idx) => idx.saturating_sub(1),
121    };
122    let line = line_idx as u32 + 1; // 1-based
123    let col = byte_offset - line_offsets[line_idx];
124    (line, col)
125}
126
127/// Complexity metrics for a single function/method/arrow.
128#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
129pub struct FunctionComplexity {
130    /// Function name (or `"<anonymous>"` for unnamed functions/arrows).
131    pub name: String,
132    /// 1-based line number where the function starts.
133    pub line: u32,
134    /// 0-based byte column where the function starts.
135    pub col: u32,
136    /// `McCabe` cyclomatic complexity (1 + decision points).
137    pub cyclomatic: u16,
138    /// `SonarSource` cognitive complexity (structural + nesting penalty).
139    pub cognitive: u16,
140    /// Number of lines in the function body.
141    pub line_count: u32,
142    /// Number of parameters (excluding TypeScript's `this` parameter).
143    pub param_count: u8,
144}
145
146/// The kind of feature flag pattern detected.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
148pub enum FlagUseKind {
149    /// `process.env.FEATURE_X` pattern.
150    EnvVar,
151    /// SDK function call like `useFlag('name')`.
152    SdkCall,
153    /// Config object access like `config.features.x`.
154    ConfigObject,
155}
156
157/// A feature flag use site detected during AST traversal.
158#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
159pub struct FlagUse {
160    /// Name/identifier of the flag (e.g., `ENABLE_NEW_CHECKOUT`, `new-checkout`).
161    pub flag_name: String,
162    /// How the flag was detected.
163    pub kind: FlagUseKind,
164    /// 1-based line number.
165    pub line: u32,
166    /// 0-based byte column offset.
167    pub col: u32,
168    /// Start byte offset of the guarded code block (if-branch span), if detected.
169    pub guard_span_start: Option<u32>,
170    /// End byte offset of the guarded code block (if-branch span), if detected.
171    pub guard_span_end: Option<u32>,
172    /// SDK/provider name if detected from SDK call pattern (e.g., "LaunchDarkly").
173    pub sdk_name: Option<String>,
174}
175
176// Size assertion: FlagUse is stored in a Vec per file in the cache.
177const _: () = assert!(std::mem::size_of::<FlagUse>() <= 96);
178
179/// A dynamic import with a pattern that can be partially resolved (e.g., template literals).
180#[derive(Debug, Clone)]
181pub struct DynamicImportPattern {
182    /// Static prefix of the import path (e.g., "./locales/"). May contain glob characters.
183    pub prefix: String,
184    /// Static suffix of the import path (e.g., ".json"), if any.
185    pub suffix: Option<String>,
186    /// Source span in the original file.
187    pub span: Span,
188}
189
190/// Visibility tag from JSDoc/TSDoc comments.
191///
192/// Controls whether an export is reported as unused. All non-`None` variants
193/// suppress unused-export detection, but preserve the semantic distinction
194/// for API surface reporting and filtering.
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
196#[serde(rename_all = "lowercase")]
197#[repr(u8)]
198pub enum VisibilityTag {
199    /// No visibility tag present.
200    #[default]
201    None = 0,
202    /// `@public` or `@api public` -- part of the public API surface.
203    Public = 1,
204    /// `@internal` -- exported for internal use (sister packages, build tools).
205    Internal = 2,
206    /// `@beta` -- public but unstable, may change without notice.
207    Beta = 3,
208    /// `@alpha` -- early preview, may change drastically without notice.
209    Alpha = 4,
210    /// `@expected-unused` -- intentionally unused, should warn when it becomes used.
211    ExpectedUnused = 5,
212}
213
214impl VisibilityTag {
215    /// Whether this tag permanently suppresses unused-export detection.
216    /// `ExpectedUnused` is handled separately (conditionally suppresses,
217    /// reports stale when the export becomes used).
218    pub const fn suppresses_unused(self) -> bool {
219        matches!(
220            self,
221            Self::Public | Self::Internal | Self::Beta | Self::Alpha
222        )
223    }
224
225    /// For serde `skip_serializing_if`.
226    pub fn is_none(&self) -> bool {
227        matches!(self, Self::None)
228    }
229}
230
231/// An export declaration.
232#[derive(Debug, Clone, serde::Serialize)]
233pub struct ExportInfo {
234    /// The exported name (named or default).
235    pub name: ExportName,
236    /// The local binding name, if different from the exported name.
237    pub local_name: Option<String>,
238    /// Whether this is a type-only export (`export type`).
239    pub is_type_only: bool,
240    /// Visibility tag from JSDoc/TSDoc comment (`@public`, `@internal`, `@alpha`, `@beta`).
241    /// Exports with any visibility tag are never reported as unused.
242    #[serde(default, skip_serializing_if = "VisibilityTag::is_none")]
243    pub visibility: VisibilityTag,
244    /// Source span of the export declaration.
245    #[serde(serialize_with = "serialize_span")]
246    pub span: Span,
247    /// Members of this export (for enums, classes, and namespaces).
248    #[serde(default, skip_serializing_if = "Vec::is_empty")]
249    pub members: Vec<MemberInfo>,
250    /// The local name of the parent class from `extends` clause, if any.
251    /// Used to build inheritance maps for unused class member detection.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub super_class: Option<String>,
254}
255
256/// Additional heritage metadata for an exported class.
257#[derive(
258    Debug,
259    Clone,
260    serde::Serialize,
261    serde::Deserialize,
262    bitcode::Encode,
263    bitcode::Decode,
264    PartialEq,
265    Eq,
266)]
267pub struct ClassHeritageInfo {
268    /// Export name (`default` for default-exported classes).
269    pub export_name: String,
270    /// Parent class name from the `extends` clause, if any.
271    pub super_class: Option<String>,
272    /// Interface names from the class `implements` clause.
273    pub implements: Vec<String>,
274}
275
276/// A member of an enum, class, or namespace.
277#[derive(Debug, Clone, serde::Serialize)]
278pub struct MemberInfo {
279    /// Member name.
280    pub name: String,
281    /// The kind of member (enum, class method/property, or namespace member).
282    pub kind: MemberKind,
283    /// Source span of the member declaration.
284    #[serde(serialize_with = "serialize_span")]
285    pub span: Span,
286    /// Whether this member has decorators (e.g., `@Column()`, `@Inject()`).
287    /// Decorated members are used by frameworks at runtime and should not be
288    /// flagged as unused class members.
289    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
290    pub has_decorator: bool,
291}
292
293/// The kind of member.
294///
295/// # Examples
296///
297/// ```
298/// use fallow_types::extract::MemberKind;
299///
300/// let kind = MemberKind::EnumMember;
301/// assert_eq!(kind, MemberKind::EnumMember);
302/// assert_ne!(kind, MemberKind::ClassMethod);
303/// assert_ne!(MemberKind::ClassMethod, MemberKind::ClassProperty);
304/// ```
305#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
306#[serde(rename_all = "snake_case")]
307pub enum MemberKind {
308    /// A TypeScript enum member.
309    EnumMember,
310    /// A class method.
311    ClassMethod,
312    /// A class property.
313    ClassProperty,
314    /// A member exported from a TypeScript namespace.
315    NamespaceMember,
316}
317
318/// A static member access expression (e.g., `Status.Active`, `MyClass.create()`).
319///
320/// # Examples
321///
322/// ```
323/// use fallow_types::extract::MemberAccess;
324///
325/// let access = MemberAccess {
326///     object: "Status".to_string(),
327///     member: "Active".to_string(),
328/// };
329/// assert_eq!(access.object, "Status");
330/// assert_eq!(access.member, "Active");
331/// ```
332#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
333pub struct MemberAccess {
334    /// The identifier being accessed (the import name).
335    pub object: String,
336    /// The member being accessed.
337    pub member: String,
338}
339
340#[expect(
341    clippy::trivially_copy_pass_by_ref,
342    reason = "serde serialize_with requires &T"
343)]
344fn serialize_span<S: serde::Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
345    use serde::ser::SerializeMap;
346    let mut map = serializer.serialize_map(Some(2))?;
347    map.serialize_entry("start", &span.start)?;
348    map.serialize_entry("end", &span.end)?;
349    map.end()
350}
351
352/// Export identifier.
353///
354/// # Examples
355///
356/// ```
357/// use fallow_types::extract::ExportName;
358///
359/// let named = ExportName::Named("foo".to_string());
360/// assert_eq!(named.to_string(), "foo");
361/// assert!(named.matches_str("foo"));
362///
363/// let default = ExportName::Default;
364/// assert_eq!(default.to_string(), "default");
365/// assert!(default.matches_str("default"));
366/// ```
367#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
368pub enum ExportName {
369    /// A named export (e.g., `export const foo`).
370    Named(String),
371    /// The default export.
372    Default,
373}
374
375impl ExportName {
376    /// Compare against a string without allocating (avoids `to_string()`).
377    #[must_use]
378    pub fn matches_str(&self, s: &str) -> bool {
379        match self {
380            Self::Named(n) => n == s,
381            Self::Default => s == "default",
382        }
383    }
384}
385
386impl std::fmt::Display for ExportName {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        match self {
389            Self::Named(n) => write!(f, "{n}"),
390            Self::Default => write!(f, "default"),
391        }
392    }
393}
394
395/// An import declaration.
396#[derive(Debug, Clone)]
397pub struct ImportInfo {
398    /// The import specifier (e.g., `./utils` or `react`).
399    pub source: String,
400    /// How the symbol is imported (named, default, namespace, or side-effect).
401    pub imported_name: ImportedName,
402    /// The local binding name in the importing module.
403    pub local_name: String,
404    /// Whether this is a type-only import (`import type`).
405    pub is_type_only: bool,
406    /// Source span of the import declaration.
407    pub span: Span,
408    /// Span of the source string literal (e.g., the `'./utils'` in `import { foo } from './utils'`).
409    /// Used by the LSP to highlight just the specifier in diagnostics.
410    pub source_span: Span,
411}
412
413/// How a symbol is imported.
414///
415/// # Examples
416///
417/// ```
418/// use fallow_types::extract::ImportedName;
419///
420/// let named = ImportedName::Named("useState".to_string());
421/// assert_eq!(named, ImportedName::Named("useState".to_string()));
422/// assert_ne!(named, ImportedName::Default);
423///
424/// // Side-effect imports have no binding
425/// assert_eq!(ImportedName::SideEffect, ImportedName::SideEffect);
426/// ```
427#[derive(Debug, Clone, PartialEq, Eq)]
428pub enum ImportedName {
429    /// A named import (e.g., `import { foo }`).
430    Named(String),
431    /// A default import (e.g., `import React`).
432    Default,
433    /// A namespace import (e.g., `import * as utils`).
434    Namespace,
435    /// A side-effect import (e.g., `import './styles.css'`).
436    SideEffect,
437}
438
439// Size assertions to prevent memory regressions in hot-path types.
440// These types are stored in Vecs inside `ModuleInfo` (one per file) and are
441// iterated during graph construction and analysis. Keeping them compact
442// improves cache locality on large projects with thousands of files.
443#[cfg(target_pointer_width = "64")]
444const _: () = assert!(std::mem::size_of::<ExportInfo>() == 112);
445#[cfg(target_pointer_width = "64")]
446const _: () = assert!(std::mem::size_of::<ImportInfo>() == 96);
447#[cfg(target_pointer_width = "64")]
448const _: () = assert!(std::mem::size_of::<ExportName>() == 24);
449#[cfg(target_pointer_width = "64")]
450const _: () = assert!(std::mem::size_of::<ImportedName>() == 24);
451#[cfg(target_pointer_width = "64")]
452const _: () = assert!(std::mem::size_of::<MemberAccess>() == 48);
453// `ModuleInfo` is the per-file extraction result — stored in a Vec during parallel parsing.
454#[cfg(target_pointer_width = "64")]
455const _: () = assert!(std::mem::size_of::<ModuleInfo>() == 400);
456
457/// A re-export declaration.
458#[derive(Debug, Clone)]
459pub struct ReExportInfo {
460    /// The module being re-exported from.
461    pub source: String,
462    /// The name imported from the source module (or `*` for star re-exports).
463    pub imported_name: String,
464    /// The name exported from this module.
465    pub exported_name: String,
466    /// Whether this is a type-only re-export.
467    pub is_type_only: bool,
468    /// Source span of the re-export declaration on this module.
469    /// Used for line-number reporting when an unused re-export is detected.
470    /// Defaults to `Span::default()` (0, 0) for re-exports without a meaningful
471    /// source location (e.g., synthesized in the graph layer).
472    pub span: oxc_span::Span,
473}
474
475/// A dynamic `import()` call.
476#[derive(Debug, Clone)]
477pub struct DynamicImportInfo {
478    /// The import specifier.
479    pub source: String,
480    /// Source span of the `import()` expression.
481    pub span: Span,
482    /// Names destructured from the dynamic import result.
483    /// Non-empty means `const { a, b } = await import(...)` -> Named imports.
484    /// Empty means simple `import(...)` or `const x = await import(...)` -> Namespace.
485    pub destructured_names: Vec<String>,
486    /// The local variable name for `const x = await import(...)`.
487    /// Used for namespace import narrowing via member access tracking.
488    pub local_name: Option<String>,
489}
490
491/// A `require()` call.
492#[derive(Debug, Clone)]
493pub struct RequireCallInfo {
494    /// The require specifier.
495    pub source: String,
496    /// Source span of the `require()` call.
497    pub span: Span,
498    /// Names destructured from the `require()` result.
499    /// Non-empty means `const { a, b } = require(...)` -> Named imports.
500    /// Empty means simple `require(...)` or `const x = require(...)` -> Namespace.
501    pub destructured_names: Vec<String>,
502    /// The local variable name for `const x = require(...)`.
503    /// Used for namespace import narrowing via member access tracking.
504    pub local_name: Option<String>,
505}
506
507/// Result of parsing all files, including incremental cache statistics.
508pub struct ParseResult {
509    /// Extracted module information for all successfully parsed files.
510    pub modules: Vec<ModuleInfo>,
511    /// Number of files whose parse results were loaded from cache (unchanged).
512    pub cache_hits: usize,
513    /// Number of files that required a full parse (new or changed).
514    pub cache_misses: usize,
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    // ── compute_line_offsets ──────────────────────────────────────────
522
523    #[test]
524    fn line_offsets_empty_string() {
525        assert_eq!(compute_line_offsets(""), vec![0]);
526    }
527
528    #[test]
529    fn line_offsets_single_line_no_newline() {
530        assert_eq!(compute_line_offsets("hello"), vec![0]);
531    }
532
533    #[test]
534    fn line_offsets_single_line_with_newline() {
535        // "hello\n" => line 0 starts at 0, line 1 starts at 6
536        assert_eq!(compute_line_offsets("hello\n"), vec![0, 6]);
537    }
538
539    #[test]
540    fn line_offsets_multiple_lines() {
541        // "abc\ndef\nghi"
542        // line 0: offset 0 ("abc")
543        // line 1: offset 4 ("def")
544        // line 2: offset 8 ("ghi")
545        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
546    }
547
548    #[test]
549    fn line_offsets_trailing_newline() {
550        // "abc\ndef\n"
551        // line 0: offset 0, line 1: offset 4, line 2: offset 8 (empty line after trailing \n)
552        assert_eq!(compute_line_offsets("abc\ndef\n"), vec![0, 4, 8]);
553    }
554
555    #[test]
556    fn line_offsets_consecutive_newlines() {
557        // "\n\n\n" = 3 newlines => 4 lines
558        assert_eq!(compute_line_offsets("\n\n\n"), vec![0, 1, 2, 3]);
559    }
560
561    #[test]
562    fn line_offsets_multibyte_utf8() {
563        // "á\n" => 'á' is 2 bytes (0xC3 0xA1), '\n' at byte 2 => next line at byte 3
564        assert_eq!(compute_line_offsets("á\n"), vec![0, 3]);
565    }
566
567    // ── byte_offset_to_line_col ──────────────────────────────────────
568
569    #[test]
570    fn line_col_offset_zero() {
571        let offsets = compute_line_offsets("abc\ndef\nghi");
572        let (line, col) = byte_offset_to_line_col(&offsets, 0);
573        assert_eq!((line, col), (1, 0)); // line 1, col 0
574    }
575
576    #[test]
577    fn line_col_middle_of_first_line() {
578        let offsets = compute_line_offsets("abc\ndef\nghi");
579        let (line, col) = byte_offset_to_line_col(&offsets, 2);
580        assert_eq!((line, col), (1, 2)); // 'c' in "abc"
581    }
582
583    #[test]
584    fn line_col_start_of_second_line() {
585        let offsets = compute_line_offsets("abc\ndef\nghi");
586        // byte 4 = start of "def"
587        let (line, col) = byte_offset_to_line_col(&offsets, 4);
588        assert_eq!((line, col), (2, 0));
589    }
590
591    #[test]
592    fn line_col_middle_of_second_line() {
593        let offsets = compute_line_offsets("abc\ndef\nghi");
594        // byte 5 = 'e' in "def"
595        let (line, col) = byte_offset_to_line_col(&offsets, 5);
596        assert_eq!((line, col), (2, 1));
597    }
598
599    #[test]
600    fn line_col_start_of_third_line() {
601        let offsets = compute_line_offsets("abc\ndef\nghi");
602        // byte 8 = start of "ghi"
603        let (line, col) = byte_offset_to_line_col(&offsets, 8);
604        assert_eq!((line, col), (3, 0));
605    }
606
607    #[test]
608    fn line_col_end_of_file() {
609        let offsets = compute_line_offsets("abc\ndef\nghi");
610        // byte 10 = 'i' (last char)
611        let (line, col) = byte_offset_to_line_col(&offsets, 10);
612        assert_eq!((line, col), (3, 2));
613    }
614
615    #[test]
616    fn line_col_single_line() {
617        let offsets = compute_line_offsets("hello");
618        let (line, col) = byte_offset_to_line_col(&offsets, 3);
619        assert_eq!((line, col), (1, 3));
620    }
621
622    #[test]
623    fn line_col_at_newline_byte() {
624        let offsets = compute_line_offsets("abc\ndef");
625        // byte 3 = the '\n' character itself, still part of line 1
626        let (line, col) = byte_offset_to_line_col(&offsets, 3);
627        assert_eq!((line, col), (1, 3));
628    }
629
630    // ── ExportName ───────────────────────────────────────────────────
631
632    #[test]
633    fn export_name_matches_str_named() {
634        let name = ExportName::Named("foo".to_string());
635        assert!(name.matches_str("foo"));
636        assert!(!name.matches_str("bar"));
637        assert!(!name.matches_str("default"));
638    }
639
640    #[test]
641    fn export_name_matches_str_default() {
642        let name = ExportName::Default;
643        assert!(name.matches_str("default"));
644        assert!(!name.matches_str("foo"));
645    }
646
647    #[test]
648    fn export_name_display_named() {
649        let name = ExportName::Named("myExport".to_string());
650        assert_eq!(name.to_string(), "myExport");
651    }
652
653    #[test]
654    fn export_name_display_default() {
655        let name = ExportName::Default;
656        assert_eq!(name.to_string(), "default");
657    }
658
659    // ── ExportName equality & hashing ────────────────────────────
660
661    #[test]
662    fn export_name_equality_named() {
663        let a = ExportName::Named("foo".to_string());
664        let b = ExportName::Named("foo".to_string());
665        let c = ExportName::Named("bar".to_string());
666        assert_eq!(a, b);
667        assert_ne!(a, c);
668    }
669
670    #[test]
671    fn export_name_equality_default() {
672        let a = ExportName::Default;
673        let b = ExportName::Default;
674        assert_eq!(a, b);
675    }
676
677    #[test]
678    fn export_name_named_not_equal_to_default() {
679        let named = ExportName::Named("default".to_string());
680        let default = ExportName::Default;
681        assert_ne!(named, default);
682    }
683
684    #[test]
685    fn export_name_hash_consistency() {
686        use std::collections::hash_map::DefaultHasher;
687        use std::hash::{Hash, Hasher};
688
689        let mut h1 = DefaultHasher::new();
690        let mut h2 = DefaultHasher::new();
691        ExportName::Named("foo".to_string()).hash(&mut h1);
692        ExportName::Named("foo".to_string()).hash(&mut h2);
693        assert_eq!(h1.finish(), h2.finish());
694    }
695
696    // ── ExportName::matches_str edge cases ───────────────────────
697
698    #[test]
699    fn export_name_matches_str_empty_string() {
700        let name = ExportName::Named(String::new());
701        assert!(name.matches_str(""));
702        assert!(!name.matches_str("foo"));
703    }
704
705    #[test]
706    fn export_name_default_does_not_match_empty() {
707        let name = ExportName::Default;
708        assert!(!name.matches_str(""));
709    }
710
711    // ── ImportedName equality ────────────────────────────────────
712
713    #[test]
714    fn imported_name_equality() {
715        assert_eq!(
716            ImportedName::Named("foo".to_string()),
717            ImportedName::Named("foo".to_string())
718        );
719        assert_ne!(
720            ImportedName::Named("foo".to_string()),
721            ImportedName::Named("bar".to_string())
722        );
723        assert_eq!(ImportedName::Default, ImportedName::Default);
724        assert_eq!(ImportedName::Namespace, ImportedName::Namespace);
725        assert_eq!(ImportedName::SideEffect, ImportedName::SideEffect);
726        assert_ne!(ImportedName::Default, ImportedName::Namespace);
727        assert_ne!(
728            ImportedName::Named("default".to_string()),
729            ImportedName::Default
730        );
731    }
732
733    // ── MemberKind equality ────────────────────────────────────
734
735    #[test]
736    fn member_kind_equality() {
737        assert_eq!(MemberKind::EnumMember, MemberKind::EnumMember);
738        assert_eq!(MemberKind::ClassMethod, MemberKind::ClassMethod);
739        assert_eq!(MemberKind::ClassProperty, MemberKind::ClassProperty);
740        assert_eq!(MemberKind::NamespaceMember, MemberKind::NamespaceMember);
741        assert_ne!(MemberKind::EnumMember, MemberKind::ClassMethod);
742        assert_ne!(MemberKind::ClassMethod, MemberKind::ClassProperty);
743        assert_ne!(MemberKind::NamespaceMember, MemberKind::EnumMember);
744    }
745
746    // ── MemberKind bitcode roundtrip ─────────────────────────────
747
748    #[test]
749    fn member_kind_bitcode_roundtrip() {
750        let kinds = [
751            MemberKind::EnumMember,
752            MemberKind::ClassMethod,
753            MemberKind::ClassProperty,
754            MemberKind::NamespaceMember,
755        ];
756        for kind in &kinds {
757            let bytes = bitcode::encode(kind);
758            let decoded: MemberKind = bitcode::decode(&bytes).unwrap();
759            assert_eq!(&decoded, kind);
760        }
761    }
762
763    // ── MemberAccess bitcode roundtrip ─────────────────────────
764
765    #[test]
766    fn member_access_bitcode_roundtrip() {
767        let access = MemberAccess {
768            object: "Status".to_string(),
769            member: "Active".to_string(),
770        };
771        let bytes = bitcode::encode(&access);
772        let decoded: MemberAccess = bitcode::decode(&bytes).unwrap();
773        assert_eq!(decoded.object, "Status");
774        assert_eq!(decoded.member, "Active");
775    }
776
777    // ── compute_line_offsets with Windows line endings ───────────
778
779    #[test]
780    fn line_offsets_crlf_only_counts_lf() {
781        // \r\n should produce offsets at the \n boundary
782        // "ab\r\ncd" => bytes: a(0) b(1) \r(2) \n(3) c(4) d(5)
783        // Line 0: offset 0, line 1: offset 4
784        let offsets = compute_line_offsets("ab\r\ncd");
785        assert_eq!(offsets, vec![0, 4]);
786    }
787
788    // ── byte_offset_to_line_col edge cases ──────────────────────
789
790    #[test]
791    fn line_col_empty_file_offset_zero() {
792        let offsets = compute_line_offsets("");
793        let (line, col) = byte_offset_to_line_col(&offsets, 0);
794        assert_eq!((line, col), (1, 0));
795    }
796
797    // ── FunctionComplexity bitcode roundtrip ──────────────────────
798
799    #[test]
800    fn function_complexity_bitcode_roundtrip() {
801        let fc = FunctionComplexity {
802            name: "processData".to_string(),
803            line: 42,
804            col: 4,
805            cyclomatic: 15,
806            cognitive: 25,
807            line_count: 80,
808            param_count: 3,
809        };
810        let bytes = bitcode::encode(&fc);
811        let decoded: FunctionComplexity = bitcode::decode(&bytes).unwrap();
812        assert_eq!(decoded.name, "processData");
813        assert_eq!(decoded.line, 42);
814        assert_eq!(decoded.col, 4);
815        assert_eq!(decoded.cyclomatic, 15);
816        assert_eq!(decoded.cognitive, 25);
817        assert_eq!(decoded.line_count, 80);
818    }
819}