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