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 /// Visibility tag from JSDoc/TSDoc comment (`@public`, `@internal`, `@alpha`, `@beta`).
247 /// Exports with any visibility tag are never reported as unused.
248 #[serde(default, skip_serializing_if = "VisibilityTag::is_none")]
249 pub visibility: VisibilityTag,
250 /// Source span of the export declaration.
251 #[serde(serialize_with = "serialize_span")]
252 pub span: Span,
253 /// Members of this export (for enums, classes, and namespaces).
254 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub members: Vec<MemberInfo>,
256 /// The local name of the parent class from `extends` clause, if any.
257 /// Used to build inheritance maps for unused class member detection.
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub super_class: Option<String>,
260}
261
262/// Additional heritage metadata for an exported class.
263#[derive(
264 Debug,
265 Clone,
266 serde::Serialize,
267 serde::Deserialize,
268 bitcode::Encode,
269 bitcode::Decode,
270 PartialEq,
271 Eq,
272)]
273pub struct ClassHeritageInfo {
274 /// Export name (`default` for default-exported classes).
275 pub export_name: String,
276 /// Parent class name from the `extends` clause, if any.
277 pub super_class: Option<String>,
278 /// Interface names from the class `implements` clause.
279 pub implements: Vec<String>,
280 /// Typed instance bindings on this class: pairs of `(local_name, type_name)`
281 /// from typed constructor parameters with accessibility modifiers
282 /// (`constructor(public svc: Svc)`) and non-private typed property
283 /// declarations (`svc: Svc`).
284 ///
285 /// Used by the analysis layer to resolve Angular template member-access
286 /// chains on external templates (`templateUrl`), where the HTML file is
287 /// parsed independently and cannot see the component's constructor types.
288 /// For `constructor(public dataService: DataService)` in a component that
289 /// uses an external template with `{{ dataService.getTotal() }}`, this
290 /// field carries `("dataService", "DataService")` so the bridge can credit
291 /// `DataService.getTotal` as used.
292 #[serde(default, skip_serializing_if = "Vec::is_empty")]
293 pub instance_bindings: Vec<(String, String)>,
294}
295
296/// A module-scope declaration that can be used as a TypeScript type.
297#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
298pub struct LocalTypeDeclaration {
299 /// Local declaration name.
300 pub name: String,
301 /// Declaration identifier span.
302 #[serde(serialize_with = "serialize_span")]
303 pub span: Span,
304}
305
306/// A reference from an exported symbol's public signature to a type name.
307#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
308pub struct PublicSignatureTypeReference {
309 /// Exported symbol whose signature contains the reference.
310 pub export_name: String,
311 /// Referenced type name. Qualified names are reduced to their root identifier.
312 pub type_name: String,
313 /// Reference span.
314 #[serde(serialize_with = "serialize_span")]
315 pub span: Span,
316}
317
318/// A member of an enum, class, or namespace.
319#[derive(Debug, Clone, serde::Serialize)]
320pub struct MemberInfo {
321 /// Member name.
322 pub name: String,
323 /// The kind of member (enum, class method/property, or namespace member).
324 pub kind: MemberKind,
325 /// Source span of the member declaration.
326 #[serde(serialize_with = "serialize_span")]
327 pub span: Span,
328 /// Whether this member has decorators (e.g., `@Column()`, `@Inject()`).
329 /// Decorated members are used by frameworks at runtime and should not be
330 /// flagged as unused class members.
331 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
332 pub has_decorator: bool,
333}
334
335/// The kind of member.
336///
337/// # Examples
338///
339/// ```
340/// use fallow_types::extract::MemberKind;
341///
342/// let kind = MemberKind::EnumMember;
343/// assert_eq!(kind, MemberKind::EnumMember);
344/// assert_ne!(kind, MemberKind::ClassMethod);
345/// assert_ne!(MemberKind::ClassMethod, MemberKind::ClassProperty);
346/// ```
347#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
348#[serde(rename_all = "snake_case")]
349pub enum MemberKind {
350 /// A TypeScript enum member.
351 EnumMember,
352 /// A class method.
353 ClassMethod,
354 /// A class property.
355 ClassProperty,
356 /// A member exported from a TypeScript namespace.
357 NamespaceMember,
358}
359
360/// A static member access expression (e.g., `Status.Active`, `MyClass.create()`).
361///
362/// # Examples
363///
364/// ```
365/// use fallow_types::extract::MemberAccess;
366///
367/// let access = MemberAccess {
368/// object: "Status".to_string(),
369/// member: "Active".to_string(),
370/// };
371/// assert_eq!(access.object, "Status");
372/// assert_eq!(access.member, "Active");
373/// ```
374#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
375pub struct MemberAccess {
376 /// The identifier being accessed (the import name).
377 pub object: String,
378 /// The member being accessed.
379 pub member: String,
380}
381
382#[expect(
383 clippy::trivially_copy_pass_by_ref,
384 reason = "serde serialize_with requires &T"
385)]
386fn serialize_span<S: serde::Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
387 use serde::ser::SerializeMap;
388 let mut map = serializer.serialize_map(Some(2))?;
389 map.serialize_entry("start", &span.start)?;
390 map.serialize_entry("end", &span.end)?;
391 map.end()
392}
393
394/// Export identifier.
395///
396/// # Examples
397///
398/// ```
399/// use fallow_types::extract::ExportName;
400///
401/// let named = ExportName::Named("foo".to_string());
402/// assert_eq!(named.to_string(), "foo");
403/// assert!(named.matches_str("foo"));
404///
405/// let default = ExportName::Default;
406/// assert_eq!(default.to_string(), "default");
407/// assert!(default.matches_str("default"));
408/// ```
409#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
410pub enum ExportName {
411 /// A named export (e.g., `export const foo`).
412 Named(String),
413 /// The default export.
414 Default,
415}
416
417impl ExportName {
418 /// Compare against a string without allocating (avoids `to_string()`).
419 #[must_use]
420 pub fn matches_str(&self, s: &str) -> bool {
421 match self {
422 Self::Named(n) => n == s,
423 Self::Default => s == "default",
424 }
425 }
426}
427
428impl std::fmt::Display for ExportName {
429 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430 match self {
431 Self::Named(n) => write!(f, "{n}"),
432 Self::Default => write!(f, "default"),
433 }
434 }
435}
436
437/// An import declaration.
438#[derive(Debug, Clone)]
439pub struct ImportInfo {
440 /// The import specifier (e.g., `./utils` or `react`).
441 pub source: String,
442 /// How the symbol is imported (named, default, namespace, or side-effect).
443 pub imported_name: ImportedName,
444 /// The local binding name in the importing module.
445 pub local_name: String,
446 /// Whether this is a type-only import (`import type`).
447 pub is_type_only: bool,
448 /// Whether this import originated from a CSS-context (an SFC `<style lang="scss">` block,
449 /// `<style src="...">` reference, or other style-section parser). The resolver uses this
450 /// to enable SCSS partial / include-path / node_modules fallbacks for SFC importers
451 /// without applying them to JS-context imports from the same file.
452 pub from_style: bool,
453 /// Source span of the import declaration.
454 pub span: Span,
455 /// Span of the source string literal (e.g., the `'./utils'` in `import { foo } from './utils'`).
456 /// Used by the LSP to highlight just the specifier in diagnostics.
457 pub source_span: Span,
458}
459
460/// How a symbol is imported.
461///
462/// # Examples
463///
464/// ```
465/// use fallow_types::extract::ImportedName;
466///
467/// let named = ImportedName::Named("useState".to_string());
468/// assert_eq!(named, ImportedName::Named("useState".to_string()));
469/// assert_ne!(named, ImportedName::Default);
470///
471/// // Side-effect imports have no binding
472/// assert_eq!(ImportedName::SideEffect, ImportedName::SideEffect);
473/// ```
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub enum ImportedName {
476 /// A named import (e.g., `import { foo }`).
477 Named(String),
478 /// A default import (e.g., `import React`).
479 Default,
480 /// A namespace import (e.g., `import * as utils`).
481 Namespace,
482 /// A side-effect import (e.g., `import './styles.css'`).
483 SideEffect,
484}
485
486// Size assertions to prevent memory regressions in hot-path types.
487// These types are stored in Vecs inside `ModuleInfo` (one per file) and are
488// iterated during graph construction and analysis. Keeping them compact
489// improves cache locality on large projects with thousands of files.
490#[cfg(target_pointer_width = "64")]
491const _: () = assert!(std::mem::size_of::<ExportInfo>() == 112);
492#[cfg(target_pointer_width = "64")]
493const _: () = assert!(std::mem::size_of::<ImportInfo>() == 96);
494#[cfg(target_pointer_width = "64")]
495const _: () = assert!(std::mem::size_of::<ExportName>() == 24);
496#[cfg(target_pointer_width = "64")]
497const _: () = assert!(std::mem::size_of::<ImportedName>() == 24);
498#[cfg(target_pointer_width = "64")]
499const _: () = assert!(std::mem::size_of::<MemberAccess>() == 48);
500// `ModuleInfo` is the per-file extraction result — stored in a Vec during parallel parsing.
501#[cfg(target_pointer_width = "64")]
502const _: () = assert!(std::mem::size_of::<ModuleInfo>() == 448);
503
504/// A re-export declaration.
505#[derive(Debug, Clone)]
506pub struct ReExportInfo {
507 /// The module being re-exported from.
508 pub source: String,
509 /// The name imported from the source module (or `*` for star re-exports).
510 pub imported_name: String,
511 /// The name exported from this module.
512 pub exported_name: String,
513 /// Whether this is a type-only re-export.
514 pub is_type_only: bool,
515 /// Source span of the re-export declaration on this module.
516 /// Used for line-number reporting when an unused re-export is detected.
517 /// Defaults to `Span::default()` (0, 0) for re-exports without a meaningful
518 /// source location (e.g., synthesized in the graph layer).
519 pub span: oxc_span::Span,
520}
521
522/// A dynamic `import()` call.
523#[derive(Debug, Clone)]
524pub struct DynamicImportInfo {
525 /// The import specifier.
526 pub source: String,
527 /// Source span of the `import()` expression.
528 pub span: Span,
529 /// Names destructured from the dynamic import result.
530 /// Non-empty means `const { a, b } = await import(...)` -> Named imports.
531 /// Empty means simple `import(...)` or `const x = await import(...)` -> Namespace.
532 pub destructured_names: Vec<String>,
533 /// The local variable name for `const x = await import(...)`.
534 /// Used for namespace import narrowing via member access tracking.
535 pub local_name: Option<String>,
536}
537
538/// A `require()` call.
539#[derive(Debug, Clone)]
540pub struct RequireCallInfo {
541 /// The require specifier.
542 pub source: String,
543 /// Source span of the `require()` call.
544 pub span: Span,
545 /// Names destructured from the `require()` result.
546 /// Non-empty means `const { a, b } = require(...)` -> Named imports.
547 /// Empty means simple `require(...)` or `const x = require(...)` -> Namespace.
548 pub destructured_names: Vec<String>,
549 /// The local variable name for `const x = require(...)`.
550 /// Used for namespace import narrowing via member access tracking.
551 pub local_name: Option<String>,
552}
553
554/// Result of parsing all files, including incremental cache statistics.
555pub struct ParseResult {
556 /// Extracted module information for all successfully parsed files.
557 pub modules: Vec<ModuleInfo>,
558 /// Number of files whose parse results were loaded from cache (unchanged).
559 pub cache_hits: usize,
560 /// Number of files that required a full parse (new or changed).
561 pub cache_misses: usize,
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 // ── compute_line_offsets ──────────────────────────────────────────
569
570 #[test]
571 fn line_offsets_empty_string() {
572 assert_eq!(compute_line_offsets(""), vec![0]);
573 }
574
575 #[test]
576 fn line_offsets_single_line_no_newline() {
577 assert_eq!(compute_line_offsets("hello"), vec![0]);
578 }
579
580 #[test]
581 fn line_offsets_single_line_with_newline() {
582 // "hello\n" => line 0 starts at 0, line 1 starts at 6
583 assert_eq!(compute_line_offsets("hello\n"), vec![0, 6]);
584 }
585
586 #[test]
587 fn line_offsets_multiple_lines() {
588 // "abc\ndef\nghi"
589 // line 0: offset 0 ("abc")
590 // line 1: offset 4 ("def")
591 // line 2: offset 8 ("ghi")
592 assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
593 }
594
595 #[test]
596 fn line_offsets_trailing_newline() {
597 // "abc\ndef\n"
598 // line 0: offset 0, line 1: offset 4, line 2: offset 8 (empty line after trailing \n)
599 assert_eq!(compute_line_offsets("abc\ndef\n"), vec![0, 4, 8]);
600 }
601
602 #[test]
603 fn line_offsets_consecutive_newlines() {
604 // "\n\n\n" = 3 newlines => 4 lines
605 assert_eq!(compute_line_offsets("\n\n\n"), vec![0, 1, 2, 3]);
606 }
607
608 #[test]
609 fn line_offsets_multibyte_utf8() {
610 // "á\n" => 'á' is 2 bytes (0xC3 0xA1), '\n' at byte 2 => next line at byte 3
611 assert_eq!(compute_line_offsets("á\n"), vec![0, 3]);
612 }
613
614 // ── byte_offset_to_line_col ──────────────────────────────────────
615
616 #[test]
617 fn line_col_offset_zero() {
618 let offsets = compute_line_offsets("abc\ndef\nghi");
619 let (line, col) = byte_offset_to_line_col(&offsets, 0);
620 assert_eq!((line, col), (1, 0)); // line 1, col 0
621 }
622
623 #[test]
624 fn line_col_middle_of_first_line() {
625 let offsets = compute_line_offsets("abc\ndef\nghi");
626 let (line, col) = byte_offset_to_line_col(&offsets, 2);
627 assert_eq!((line, col), (1, 2)); // 'c' in "abc"
628 }
629
630 #[test]
631 fn line_col_start_of_second_line() {
632 let offsets = compute_line_offsets("abc\ndef\nghi");
633 // byte 4 = start of "def"
634 let (line, col) = byte_offset_to_line_col(&offsets, 4);
635 assert_eq!((line, col), (2, 0));
636 }
637
638 #[test]
639 fn line_col_middle_of_second_line() {
640 let offsets = compute_line_offsets("abc\ndef\nghi");
641 // byte 5 = 'e' in "def"
642 let (line, col) = byte_offset_to_line_col(&offsets, 5);
643 assert_eq!((line, col), (2, 1));
644 }
645
646 #[test]
647 fn line_col_start_of_third_line() {
648 let offsets = compute_line_offsets("abc\ndef\nghi");
649 // byte 8 = start of "ghi"
650 let (line, col) = byte_offset_to_line_col(&offsets, 8);
651 assert_eq!((line, col), (3, 0));
652 }
653
654 #[test]
655 fn line_col_end_of_file() {
656 let offsets = compute_line_offsets("abc\ndef\nghi");
657 // byte 10 = 'i' (last char)
658 let (line, col) = byte_offset_to_line_col(&offsets, 10);
659 assert_eq!((line, col), (3, 2));
660 }
661
662 #[test]
663 fn line_col_single_line() {
664 let offsets = compute_line_offsets("hello");
665 let (line, col) = byte_offset_to_line_col(&offsets, 3);
666 assert_eq!((line, col), (1, 3));
667 }
668
669 #[test]
670 fn line_col_at_newline_byte() {
671 let offsets = compute_line_offsets("abc\ndef");
672 // byte 3 = the '\n' character itself, still part of line 1
673 let (line, col) = byte_offset_to_line_col(&offsets, 3);
674 assert_eq!((line, col), (1, 3));
675 }
676
677 // ── ExportName ───────────────────────────────────────────────────
678
679 #[test]
680 fn export_name_matches_str_named() {
681 let name = ExportName::Named("foo".to_string());
682 assert!(name.matches_str("foo"));
683 assert!(!name.matches_str("bar"));
684 assert!(!name.matches_str("default"));
685 }
686
687 #[test]
688 fn export_name_matches_str_default() {
689 let name = ExportName::Default;
690 assert!(name.matches_str("default"));
691 assert!(!name.matches_str("foo"));
692 }
693
694 #[test]
695 fn export_name_display_named() {
696 let name = ExportName::Named("myExport".to_string());
697 assert_eq!(name.to_string(), "myExport");
698 }
699
700 #[test]
701 fn export_name_display_default() {
702 let name = ExportName::Default;
703 assert_eq!(name.to_string(), "default");
704 }
705
706 // ── ExportName equality & hashing ────────────────────────────
707
708 #[test]
709 fn export_name_equality_named() {
710 let a = ExportName::Named("foo".to_string());
711 let b = ExportName::Named("foo".to_string());
712 let c = ExportName::Named("bar".to_string());
713 assert_eq!(a, b);
714 assert_ne!(a, c);
715 }
716
717 #[test]
718 fn export_name_equality_default() {
719 let a = ExportName::Default;
720 let b = ExportName::Default;
721 assert_eq!(a, b);
722 }
723
724 #[test]
725 fn export_name_named_not_equal_to_default() {
726 let named = ExportName::Named("default".to_string());
727 let default = ExportName::Default;
728 assert_ne!(named, default);
729 }
730
731 #[test]
732 fn export_name_hash_consistency() {
733 use std::collections::hash_map::DefaultHasher;
734 use std::hash::{Hash, Hasher};
735
736 let mut h1 = DefaultHasher::new();
737 let mut h2 = DefaultHasher::new();
738 ExportName::Named("foo".to_string()).hash(&mut h1);
739 ExportName::Named("foo".to_string()).hash(&mut h2);
740 assert_eq!(h1.finish(), h2.finish());
741 }
742
743 // ── ExportName::matches_str edge cases ───────────────────────
744
745 #[test]
746 fn export_name_matches_str_empty_string() {
747 let name = ExportName::Named(String::new());
748 assert!(name.matches_str(""));
749 assert!(!name.matches_str("foo"));
750 }
751
752 #[test]
753 fn export_name_default_does_not_match_empty() {
754 let name = ExportName::Default;
755 assert!(!name.matches_str(""));
756 }
757
758 // ── ImportedName equality ────────────────────────────────────
759
760 #[test]
761 fn imported_name_equality() {
762 assert_eq!(
763 ImportedName::Named("foo".to_string()),
764 ImportedName::Named("foo".to_string())
765 );
766 assert_ne!(
767 ImportedName::Named("foo".to_string()),
768 ImportedName::Named("bar".to_string())
769 );
770 assert_eq!(ImportedName::Default, ImportedName::Default);
771 assert_eq!(ImportedName::Namespace, ImportedName::Namespace);
772 assert_eq!(ImportedName::SideEffect, ImportedName::SideEffect);
773 assert_ne!(ImportedName::Default, ImportedName::Namespace);
774 assert_ne!(
775 ImportedName::Named("default".to_string()),
776 ImportedName::Default
777 );
778 }
779
780 // ── MemberKind equality ────────────────────────────────────
781
782 #[test]
783 fn member_kind_equality() {
784 assert_eq!(MemberKind::EnumMember, MemberKind::EnumMember);
785 assert_eq!(MemberKind::ClassMethod, MemberKind::ClassMethod);
786 assert_eq!(MemberKind::ClassProperty, MemberKind::ClassProperty);
787 assert_eq!(MemberKind::NamespaceMember, MemberKind::NamespaceMember);
788 assert_ne!(MemberKind::EnumMember, MemberKind::ClassMethod);
789 assert_ne!(MemberKind::ClassMethod, MemberKind::ClassProperty);
790 assert_ne!(MemberKind::NamespaceMember, MemberKind::EnumMember);
791 }
792
793 // ── MemberKind bitcode roundtrip ─────────────────────────────
794
795 #[test]
796 fn member_kind_bitcode_roundtrip() {
797 let kinds = [
798 MemberKind::EnumMember,
799 MemberKind::ClassMethod,
800 MemberKind::ClassProperty,
801 MemberKind::NamespaceMember,
802 ];
803 for kind in &kinds {
804 let bytes = bitcode::encode(kind);
805 let decoded: MemberKind = bitcode::decode(&bytes).unwrap();
806 assert_eq!(&decoded, kind);
807 }
808 }
809
810 // ── MemberAccess bitcode roundtrip ─────────────────────────
811
812 #[test]
813 fn member_access_bitcode_roundtrip() {
814 let access = MemberAccess {
815 object: "Status".to_string(),
816 member: "Active".to_string(),
817 };
818 let bytes = bitcode::encode(&access);
819 let decoded: MemberAccess = bitcode::decode(&bytes).unwrap();
820 assert_eq!(decoded.object, "Status");
821 assert_eq!(decoded.member, "Active");
822 }
823
824 // ── compute_line_offsets with Windows line endings ───────────
825
826 #[test]
827 fn line_offsets_crlf_only_counts_lf() {
828 // \r\n should produce offsets at the \n boundary
829 // "ab\r\ncd" => bytes: a(0) b(1) \r(2) \n(3) c(4) d(5)
830 // Line 0: offset 0, line 1: offset 4
831 let offsets = compute_line_offsets("ab\r\ncd");
832 assert_eq!(offsets, vec![0, 4]);
833 }
834
835 // ── byte_offset_to_line_col edge cases ──────────────────────
836
837 #[test]
838 fn line_col_empty_file_offset_zero() {
839 let offsets = compute_line_offsets("");
840 let (line, col) = byte_offset_to_line_col(&offsets, 0);
841 assert_eq!((line, col), (1, 0));
842 }
843
844 // ── FunctionComplexity bitcode roundtrip ──────────────────────
845
846 #[test]
847 fn function_complexity_bitcode_roundtrip() {
848 let fc = FunctionComplexity {
849 name: "processData".to_string(),
850 line: 42,
851 col: 4,
852 cyclomatic: 15,
853 cognitive: 25,
854 line_count: 80,
855 param_count: 3,
856 };
857 let bytes = bitcode::encode(&fc);
858 let decoded: FunctionComplexity = bitcode::decode(&bytes).unwrap();
859 assert_eq!(decoded.name, "processData");
860 assert_eq!(decoded.line, 42);
861 assert_eq!(decoded.col, 4);
862 assert_eq!(decoded.cyclomatic, 15);
863 assert_eq!(decoded.cognitive, 25);
864 assert_eq!(decoded.line_count, 80);
865 }
866}