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