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