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