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