Skip to main content

fallow_types/
extract.rs

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