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