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