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