Skip to main content

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