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