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