fallow_types/extract.rs
1//! Module extraction types.
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.
22 pub dynamic_import_patterns: Vec<DynamicImportPattern>,
23 /// All `require()` calls.
24 pub require_calls: Vec<RequireCallInfo>,
25 /// Package names statically referenced through package path resolution.
26 pub package_path_references: Vec<String>,
27 /// Static member access expressions (e.g., `Status.Active`).
28 pub member_accesses: Vec<MemberAccess>,
29 /// Identifiers used in whole-object access patterns.
30 pub whole_object_uses: Vec<String>,
31 /// Whether this module uses CommonJS exports.
32 pub has_cjs_exports: bool,
33 /// Whether this module declares an Angular component `templateUrl`.
34 pub has_angular_component_template_url: bool,
35 /// xxh3 hash of the file content for incremental caching.
36 pub content_hash: u64,
37 /// Inline suppression directives parsed from comments.
38 pub suppressions: Vec<Suppression>,
39 /// Suppression tokens that did not parse to any known `IssueKind`.
40 /// Surfaced as `StaleSuppression` findings via `find_stale` so users see
41 /// typos or obsolete kind names instead of having the entire marker
42 /// silently discarded. See issue #449.
43 pub unknown_suppression_kinds: Vec<UnknownSuppressionKind>,
44 /// Local names of import bindings that are never referenced in this file.
45 /// Populated via `oxc_semantic` scope analysis. Used at graph-build time
46 /// to skip adding references for imports whose binding is never read,
47 /// improving unused-export detection precision.
48 pub unused_import_bindings: Vec<String>,
49 /// Local import bindings that are referenced from TypeScript type positions.
50 /// Used to distinguish value-namespace and type-namespace references when a
51 /// module exports both `const X` and `type X`.
52 pub type_referenced_import_bindings: Vec<String>,
53 /// Local import bindings referenced from runtime/value positions.
54 pub value_referenced_import_bindings: Vec<String>,
55 /// Pre-computed byte offsets where each line starts.
56 pub line_offsets: Vec<u32>,
57 /// Per-function complexity metrics.
58 pub complexity: Vec<FunctionComplexity>,
59 /// Feature flag use sites.
60 pub flag_uses: Vec<FlagUse>,
61 /// Heritage metadata for exported classes that declare `implements`.
62 pub class_heritage: Vec<ClassHeritageInfo>,
63 /// Angular `InjectionToken<Interface>` declarations, as
64 /// `(token_export_name, interface_name)` pairs. Recorded only for
65 /// `new InjectionToken<I>(...)` initializers whose `InjectionToken` is
66 /// imported from `@angular/core`. The analyze layer follows the token's
67 /// interface type argument to the classes that `implement` it so a template
68 /// member call through `inject(TOKEN)` credits the concrete implementation.
69 /// See issue #920 (follow-up to #911 / #913).
70 pub injection_tokens: Vec<(String, String)>,
71 /// Local type-capable declarations.
72 pub local_type_declarations: Vec<LocalTypeDeclaration>,
73 /// Type references in exported public signatures.
74 pub public_signature_type_references: Vec<PublicSignatureTypeReference>,
75 /// Aliases of namespace imports re-exported through an object literal.
76 pub namespace_object_aliases: Vec<NamespaceObjectAlias>,
77 /// Deduped Iconify collection prefixes found in static icon props.
78 pub iconify_prefixes: Vec<String>,
79 /// Deduped Nuxt UI `i-<collection>-<icon>` icon class suffixes found in
80 /// static script-side icon properties.
81 pub iconify_icon_names: Vec<String>,
82 /// Bare identifiers that may be resolved by framework auto-imports.
83 pub auto_import_candidates: Vec<String>,
84 /// File-level string directives in source order (e.g. `"use client"`,
85 /// `"use server"`, `"use strict"`). Captured from `Program::directives`.
86 /// Consumed by the security `client-server-leak` detector to identify
87 /// React Server Component client boundaries.
88 pub directives: Vec<String>,
89 /// Captured non-literal security sink sites (category-blind). Consumed by
90 /// the catalogue-driven `tainted_sink` detector. Captured only by JS/TS
91 /// extraction; empty for CSS/MDX/etc. See `security_matchers.toml`.
92 pub security_sinks: Vec<SinkSite>,
93 /// Count of sink-shaped nodes whose callee could not be flattened to a
94 /// static path (dynamic dispatch, computed members, aliased bindings).
95 /// Surfaced in-band so an empty catalogue result with a non-zero count is
96 /// not a clean bill.
97 pub security_sinks_skipped: u32,
98 /// Local bindings whose initializer (or destructured object) is a flattened
99 /// member-access path. Used by the security `tainted_sink` detector to
100 /// back-trace a sink argument to a known untrusted source: the analyze layer
101 /// matches each binding's `source_path` against the data-driven source
102 /// catalogue (`security_matchers.toml` `[[source]]` rows) and treats the
103 /// matching `local` names as source-tainted. Intra-module and name-based
104 /// (no scope analysis); a conservative association, never a taint proof.
105 pub tainted_bindings: Vec<TaintedBinding>,
106 /// Sink arguments that were recognized as sanitizer calls at extraction
107 /// time. Used for direct sink calls such as
108 /// `el.innerHTML = DOMPurify.sanitize(input)`.
109 pub sanitized_sink_args: Vec<SanitizedSinkArg>,
110}
111
112/// Sanitizer output domain. Kept intentionally narrow so a sanitizer for one
113/// domain cannot suppress a different sink family.
114#[derive(
115 Debug,
116 Clone,
117 Copy,
118 PartialEq,
119 Eq,
120 serde::Serialize,
121 serde::Deserialize,
122 bitcode::Encode,
123 bitcode::Decode,
124)]
125pub enum SanitizerScope {
126 /// HTML markup sanitized by DOMPurify-compatible APIs.
127 Html,
128 /// URL or redirect target checked against a literal-backed allowlist.
129 Url,
130 /// Path value checked against a high-confidence containment guard.
131 Path,
132}
133
134/// A captured sink argument that is itself a recognized sanitizer call.
135#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
136pub struct SanitizedSinkArg {
137 /// Byte offset of the owning sink span start.
138 pub span_start: u32,
139 /// The positional argument index on the owning sink.
140 pub arg_index: u32,
141 /// The sanitizer output domain for this argument.
142 pub scope: SanitizerScope,
143}
144
145/// A local binding tied to the flattened member-access path it was initialized
146/// from. The analyze layer matches `source_path` against the data-driven source
147/// catalogue; when it matches, `local` is treated as carrying untrusted input.
148///
149/// Captured for two shapes: a direct assignment (`const id = req.query.id` ->
150/// `{ local: "id", source_path: "req.query" }`, the literal-key tail dropped so
151/// the path matches a catalogue prefix) and an object destructure
152/// (`const { id } = req.query` -> `{ local: "id", source_path: "req.query" }`).
153#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
154pub struct TaintedBinding {
155 /// The local binding name introduced by the declarator.
156 pub local: String,
157 /// The flattened object member-access path the binding was sourced from.
158 pub source_path: String,
159}
160
161/// The syntactic shape of a captured security sink site. Category-blind: the
162/// extractor records the shape and the dotted/bare callee path; the analyze
163/// layer matches it against the data-driven catalogue. See
164/// `crates/core/data/security_matchers.toml`.
165#[derive(
166 Debug,
167 Clone,
168 Copy,
169 PartialEq,
170 Eq,
171 serde::Serialize,
172 serde::Deserialize,
173 bitcode::Encode,
174 bitcode::Decode,
175)]
176pub enum SinkShape {
177 /// A call to a bare identifier (e.g. `eval(x)`).
178 Call,
179 /// A call to a dotted member path (e.g. `child_process.exec(x)`).
180 MemberCall,
181 /// An assignment to a member target (e.g. `el.innerHTML = x`).
182 MemberAssign,
183 /// A tagged template expression (e.g. ``sql`...${x}...` ``).
184 TaggedTemplate,
185 /// A JSX attribute value (e.g. `dangerouslySetInnerHTML={x}`).
186 JsxAttr,
187}
188
189/// The shape of the non-literal argument captured at a sink site. Category-blind
190/// like [`SinkShape`], but finer-grained: it lets the catalogue matcher require
191/// or exclude specific argument shapes. The discriminator is what distinguishes
192/// an unsafe SQL string concatenation or template-into-`.execute()` from a
193/// safely-parameterized `` sql`${x}` `` tagged template or an object-literal
194/// `.execute({ sql, args })` argument.
195#[derive(
196 Debug,
197 Clone,
198 Copy,
199 PartialEq,
200 Eq,
201 serde::Serialize,
202 serde::Deserialize,
203 bitcode::Encode,
204 bitcode::Decode,
205)]
206pub enum SinkArgKind {
207 /// A template literal with at least one `${...}` substitution (e.g.
208 /// `` `SELECT ${x}` ``). On a `tagged-template` shape this is the tag's
209 /// quasi; on a `call`/`member-call` shape it is the positional argument.
210 TemplateWithSubst,
211 /// A binary `+` string concatenation (e.g. `"SELECT " + x`).
212 Concat,
213 /// An object literal (e.g. `.execute({ sql, args })`, the parameterized form).
214 Object,
215 /// A call expression argument (e.g. `query(buildSql())`).
216 Call,
217 /// Any other non-literal expression (bare identifier, member access, etc.).
218 Other,
219}
220
221/// A captured non-literal sink site. The visitor records EVERY call /
222/// member-assign / member-call / tagged-template / jsx-attr whose relevant
223/// argument is non-literal; it knows nothing about CWE categories. A
224/// fully-literal argument is never captured (conservative trigger).
225#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
226pub struct SinkSite {
227 /// The syntactic shape of the sink site.
228 pub sink_shape: SinkShape,
229 /// The flattened dotted/bare callee or member path.
230 pub callee_path: String,
231 /// The positional index of the non-literal argument.
232 pub arg_index: u32,
233 /// Whether the relevant argument is non-literal (always true when captured).
234 pub arg_is_non_literal: bool,
235 /// The finer-grained shape of the captured non-literal argument. Lets the
236 /// catalogue require unsafe shapes (concat / template-with-substitution) and
237 /// exclude safe ones (object literal, the parameterized form). See
238 /// [`SinkArgKind`].
239 pub arg_kind: SinkArgKind,
240 /// Identifier names referenced anywhere inside the captured non-literal sink
241 /// argument (deduped, source order). Used by the analyze layer to back-trace
242 /// the sink argument to a known untrusted source: a sink is "source-backed"
243 /// when one of these names was bound from a source-shaped expression (see
244 /// `ModuleInfo::tainted_bindings`). Intra-module, name-based, conservative;
245 /// it is never a taint proof. Empty when the argument references no bare
246 /// identifiers (e.g. a pure member-call result).
247 pub arg_idents: Vec<String>,
248 /// Byte offset of the sink span start. Stored as `u32` (not `Span`) so the
249 /// struct is bitcode-encodable and can be persisted directly in the cache.
250 pub span_start: u32,
251 /// Byte offset of the sink span end.
252 pub span_end: u32,
253}
254
255impl SinkSite {
256 /// Reconstruct the source span from the stored byte offsets.
257 #[must_use]
258 pub fn span(&self) -> Span {
259 Span::new(self.span_start, self.span_end)
260 }
261}
262
263/// One alias entry tying an exported object's dotted property path to a namespace import.
264#[derive(Debug, Clone)]
265pub struct NamespaceObjectAlias {
266 /// Canonical export name.
267 pub via_export_name: String,
268 /// Dotted suffix of the property path relative to the export.
269 pub suffix: String,
270 /// Local name of the namespace import.
271 pub namespace_local: String,
272}
273
274/// Compute a table of line-start byte offsets from source text.
275#[must_use]
276#[expect(
277 clippy::cast_possible_truncation,
278 reason = "source files are practically < 4GB"
279)]
280pub fn compute_line_offsets(source: &str) -> Vec<u32> {
281 let mut offsets = vec![0u32];
282 for (i, byte) in source.bytes().enumerate() {
283 if byte == b'\n' {
284 debug_assert!(
285 u32::try_from(i + 1).is_ok(),
286 "source file exceeds u32::MAX bytes — line offsets would overflow"
287 );
288 offsets.push((i + 1) as u32);
289 }
290 }
291 offsets
292}
293
294/// Convert a byte offset to a 1-based line number and 0-based byte column.
295#[must_use]
296#[expect(
297 clippy::cast_possible_truncation,
298 reason = "line count is bounded by source size"
299)]
300pub fn byte_offset_to_line_col(line_offsets: &[u32], byte_offset: u32) -> (u32, u32) {
301 let line_idx = match line_offsets.binary_search(&byte_offset) {
302 Ok(idx) => idx,
303 Err(idx) => idx.saturating_sub(1),
304 };
305 let line = line_idx as u32 + 1;
306 let col = byte_offset - line_offsets[line_idx];
307 (line, col)
308}
309
310/// Complexity metrics for a single function/method/arrow.
311#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
312pub struct FunctionComplexity {
313 /// Function name (or `"<anonymous>"` for unnamed functions/arrows).
314 pub name: String,
315 /// 1-based line number where the function starts.
316 pub line: u32,
317 /// 0-based byte column where the function starts.
318 pub col: u32,
319 /// `McCabe` cyclomatic complexity (1 + decision points).
320 pub cyclomatic: u16,
321 /// `SonarSource` cognitive complexity (structural + nesting penalty).
322 pub cognitive: u16,
323 /// Number of lines in the function body.
324 pub line_count: u32,
325 /// Number of parameters (excluding TypeScript's `this` parameter).
326 pub param_count: u8,
327 /// Content digest of the function's full-span source slice.
328 pub source_hash: Option<String>,
329 /// Per-decision-point breakdown explaining WHICH constructs drove the
330 /// cyclomatic and cognitive scores. One entry per increment event (an `if`
331 /// emits one cyclomatic and one cognitive entry at the same line, because
332 /// the two metrics accrue at different granularities). Always computed and
333 /// cached; surfaced in JSON only behind `health --complexity-breakdown`.
334 pub contributions: Vec<ComplexityContribution>,
335}
336
337/// Which complexity metric a [`ComplexityContribution`] adds to.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
339#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
340#[serde(rename_all = "kebab-case")]
341pub enum ComplexityMetric {
342 /// `McCabe` cyclomatic complexity (independent execution paths).
343 Cyclomatic,
344 /// `SonarSource` cognitive complexity (structural + nesting penalty).
345 Cognitive,
346}
347
348/// The syntactic construct that produced a single complexity increment.
349///
350/// Mirrors `SonarSource` cognitive-complexity vocabulary where it overlaps.
351/// `Case` means a `case` label carrying a test; a bare `default` adds nothing
352/// to cyclomatic complexity and so produces no contribution.
353#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
354#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
355#[serde(rename_all = "kebab-case")]
356pub enum ComplexityContributionKind {
357 /// An `if` condition.
358 If,
359 /// A bare `else` branch (cognitive only).
360 Else,
361 /// An `else if` continuation (both metrics: cyclomatic +1, cognitive flat
362 /// +1 with no nesting penalty).
363 ElseIf,
364 /// A `?:` conditional (ternary) expression.
365 Ternary,
366 /// A logical `&&` operator.
367 LogicalAnd,
368 /// A logical `||` operator.
369 LogicalOr,
370 /// A `??` nullish-coalescing operator.
371 NullishCoalescing,
372 /// A logical assignment operator (`&&=`, `||=`, `??=`); cyclomatic only.
373 LogicalAssignment,
374 /// An optional-chaining link (`?.`); cyclomatic only.
375 OptionalChain,
376 /// A `for` loop.
377 For,
378 /// A `for...in` loop.
379 ForIn,
380 /// A `for...of` loop.
381 ForOf,
382 /// A `while` loop.
383 While,
384 /// A `do...while` loop.
385 DoWhile,
386 /// A `switch` statement (cognitive only; each `case` adds cyclomatic).
387 Switch,
388 /// A `case` label carrying a test (cyclomatic only).
389 Case,
390 /// A `catch` clause.
391 Catch,
392 /// A labeled `break` (cognitive only).
393 LabeledBreak,
394 /// A labeled `continue` (cognitive only).
395 LabeledContinue,
396}
397
398/// A single complexity increment, located at its source line/column.
399///
400/// `weight` is the amount this construct added to `metric`; for nested
401/// cognitive increments `weight == 1 + nesting`. Consumers that render inline
402/// (the VS Code editor breakdown) group contributions by `line` and sum the
403/// weights, deferring the per-kind list to a hover.
404#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
405#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
406pub struct ComplexityContribution {
407 /// 1-based line number where the construct begins.
408 pub line: u32,
409 /// 0-based byte column where the construct begins.
410 pub col: u32,
411 /// Which metric this increment contributes to.
412 pub metric: ComplexityMetric,
413 /// The syntactic construct responsible for the increment.
414 pub kind: ComplexityContributionKind,
415 /// The amount added to `metric` at this site (`1 + nesting` for nested
416 /// cognitive increments, otherwise `1`).
417 pub weight: u16,
418 /// The nesting depth at the increment site (`0` when not nested). Lets a
419 /// consumer explain a cognitive `+3` as "+1 base, +2 nesting".
420 pub nesting: u16,
421}
422
423/// The kind of feature flag pattern detected.
424#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
425pub enum FlagUseKind {
426 /// `process.env.FEATURE_X` pattern.
427 EnvVar,
428 /// SDK function call like `useFlag('name')`.
429 SdkCall,
430 /// Config object access like `config.features.x`.
431 ConfigObject,
432}
433
434/// A feature flag use site.
435#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
436pub struct FlagUse {
437 /// Flag identifier.
438 pub flag_name: String,
439 /// Detection kind.
440 pub kind: FlagUseKind,
441 /// 1-based line number.
442 pub line: u32,
443 /// 0-based byte column offset.
444 pub col: u32,
445 /// Start byte offset of the guarded block.
446 pub guard_span_start: Option<u32>,
447 /// End byte offset of the guarded block.
448 pub guard_span_end: Option<u32>,
449 /// SDK/provider name.
450 pub sdk_name: Option<String>,
451}
452
453const _: () = assert!(std::mem::size_of::<FlagUse>() <= 96);
454
455/// A dynamic import with a partially resolved pattern.
456#[derive(Debug, Clone)]
457pub struct DynamicImportPattern {
458 /// Static prefix of the import path (e.g., "./locales/"). May contain glob characters.
459 pub prefix: String,
460 /// Static suffix of the import path (e.g., ".json"), if any.
461 pub suffix: Option<String>,
462 /// Source span in the original file.
463 pub span: Span,
464}
465
466/// Visibility tag from JSDoc/TSDoc comments that suppresses unused-export detection.
467#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
468#[serde(rename_all = "lowercase")]
469#[repr(u8)]
470pub enum VisibilityTag {
471 /// No visibility tag present.
472 #[default]
473 None = 0,
474 /// `@public` or `@api public` -- part of the public API surface.
475 Public = 1,
476 /// `@internal` -- exported for internal use (sister packages, build tools).
477 Internal = 2,
478 /// `@beta` -- public but unstable, may change without notice.
479 Beta = 3,
480 /// `@alpha` -- early preview, may change drastically without notice.
481 Alpha = 4,
482 /// `@expected-unused` -- intentionally unused, should warn when it becomes used.
483 ExpectedUnused = 5,
484}
485
486impl VisibilityTag {
487 /// Whether this tag permanently suppresses unused-export detection.
488 /// `ExpectedUnused` is handled separately (conditionally suppresses,
489 /// reports stale when the export becomes used).
490 pub const fn suppresses_unused(self) -> bool {
491 matches!(
492 self,
493 Self::Public | Self::Internal | Self::Beta | Self::Alpha
494 )
495 }
496
497 /// For serde `skip_serializing_if`.
498 pub fn is_none(&self) -> bool {
499 matches!(self, Self::None)
500 }
501}
502
503/// An export declaration.
504#[derive(Debug, Clone, serde::Serialize)]
505pub struct ExportInfo {
506 /// The exported name (named or default).
507 pub name: ExportName,
508 /// The local binding name, if different from the exported name.
509 pub local_name: Option<String>,
510 /// Whether this is a type-only export (`export type`).
511 pub is_type_only: bool,
512 /// Whether this export is registered through a runtime side effect at module load time.
513 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
514 pub is_side_effect_used: bool,
515 /// Visibility tag from JSDoc/TSDoc comment.
516 #[serde(default, skip_serializing_if = "VisibilityTag::is_none")]
517 pub visibility: VisibilityTag,
518 /// Source span of the export declaration.
519 #[serde(serialize_with = "serialize_span")]
520 pub span: Span,
521 /// Members of this export (for enums, classes, and namespaces).
522 #[serde(default, skip_serializing_if = "Vec::is_empty")]
523 pub members: Vec<MemberInfo>,
524 /// The local name of the parent class from `extends` clause, if any.
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub super_class: Option<String>,
527}
528
529/// Additional heritage metadata for an exported class.
530#[derive(
531 Debug,
532 Clone,
533 serde::Serialize,
534 serde::Deserialize,
535 bitcode::Encode,
536 bitcode::Decode,
537 PartialEq,
538 Eq,
539)]
540pub struct ClassHeritageInfo {
541 /// Export name (`default` for default-exported classes).
542 pub export_name: String,
543 /// Parent class name from the `extends` clause, if any.
544 pub super_class: Option<String>,
545 /// Interface names from the class `implements` clause.
546 pub implements: Vec<String>,
547 /// Typed instance bindings used to resolve member-access chains in external templates.
548 #[serde(default, skip_serializing_if = "Vec::is_empty")]
549 pub instance_bindings: Vec<(String, String)>,
550}
551
552/// A module-scope declaration that can be used as a TypeScript type.
553#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
554pub struct LocalTypeDeclaration {
555 /// Local declaration name.
556 pub name: String,
557 /// Declaration identifier span.
558 #[serde(serialize_with = "serialize_span")]
559 pub span: Span,
560}
561
562/// A reference from an exported symbol's public signature to a type name.
563#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
564pub struct PublicSignatureTypeReference {
565 /// Exported symbol whose signature contains the reference.
566 pub export_name: String,
567 /// Referenced type name. Qualified names are reduced to their root identifier.
568 pub type_name: String,
569 /// Reference span.
570 #[serde(serialize_with = "serialize_span")]
571 pub span: Span,
572}
573
574/// A member of an enum, class, or namespace.
575#[derive(Debug, Clone, serde::Serialize)]
576pub struct MemberInfo {
577 /// Member name.
578 pub name: String,
579 /// The kind of member (enum, class method/property, or namespace member).
580 pub kind: MemberKind,
581 /// Source span of the member declaration.
582 #[serde(serialize_with = "serialize_span")]
583 pub span: Span,
584 /// Whether this member has decorators (e.g., `@Column()`, `@Inject()`).
585 /// Decorated members are used by frameworks at runtime and should not be
586 /// flagged as unused class members, unless every decorator on the member
587 /// is opted out via `FallowConfig.ignore_decorators`.
588 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
589 pub has_decorator: bool,
590 /// Full dotted path of each decorator on this member, in source order.
591 /// `@step("x")` stores `"step"`; `@ns.foo` stores `"ns.foo"`. Empty for
592 /// undecorated members, Angular signal-initializer properties (which set
593 /// `has_decorator` without a literal decorator AST node), and decorators
594 /// whose expression is not an identifier ladder (the entry is the empty
595 /// string in that case, treated as never-matching by the predicate).
596 #[serde(default, skip_serializing_if = "Vec::is_empty")]
597 pub decorator_names: Vec<String>,
598 /// True when this is a static class method that returns a fresh instance
599 /// of the same class: either via `return new this()` / `return new
600 /// <SameClassName>()` in the body's last statement, or via a declared
601 /// return type matching the class name. Consumers calling such a static
602 /// method receive an instance, so the call result's member accesses are
603 /// credited against the class. See issues #346, #387.
604 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
605 pub is_instance_returning_static: bool,
606 /// True when this is an instance class method whose call result is an
607 /// instance of the same class. Qualifies when the declared return type
608 /// matches the class name (`setX(): EventBuilder { ... }`) or when the
609 /// body's last statement is `return this`. The analyze layer walks fluent
610 /// chains (`Class.factory().setX().setY()`) only through methods carrying
611 /// this flag, so the chain stops at a non-self-returning method like
612 /// `.build()`. See issue #387.
613 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
614 pub is_self_returning: bool,
615}
616
617/// The kind of member.
618#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
619#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
620#[serde(rename_all = "snake_case")]
621pub enum MemberKind {
622 /// A TypeScript enum member.
623 EnumMember,
624 /// A class method.
625 ClassMethod,
626 /// A class property.
627 ClassProperty,
628 /// A member exported from a TypeScript namespace.
629 NamespaceMember,
630}
631
632/// A static member access expression (e.g., `Status.Active`, `MyClass.create()`).
633#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
634pub struct MemberAccess {
635 /// The identifier being accessed (the import name).
636 pub object: String,
637 /// The member being accessed.
638 pub member: String,
639}
640
641#[expect(
642 clippy::trivially_copy_pass_by_ref,
643 reason = "serde serialize_with requires &T"
644)]
645fn serialize_span<S: serde::Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
646 use serde::ser::SerializeMap;
647 let mut map = serializer.serialize_map(Some(2))?;
648 map.serialize_entry("start", &span.start)?;
649 map.serialize_entry("end", &span.end)?;
650 map.end()
651}
652
653/// Export identifier.
654#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
655pub enum ExportName {
656 /// A named export (e.g., `export const foo`).
657 Named(String),
658 /// The default export.
659 Default,
660}
661
662impl ExportName {
663 /// Compare against a string without allocating (avoids `to_string()`).
664 #[must_use]
665 pub fn matches_str(&self, s: &str) -> bool {
666 match self {
667 Self::Named(n) => n == s,
668 Self::Default => s == "default",
669 }
670 }
671}
672
673impl std::fmt::Display for ExportName {
674 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675 match self {
676 Self::Named(n) => write!(f, "{n}"),
677 Self::Default => write!(f, "default"),
678 }
679 }
680}
681
682/// An import declaration.
683#[derive(Debug, Clone)]
684pub struct ImportInfo {
685 /// The import specifier (e.g., `./utils` or `react`).
686 pub source: String,
687 /// How the symbol is imported (named, default, namespace, or side-effect).
688 pub imported_name: ImportedName,
689 /// The local binding name in the importing module.
690 pub local_name: String,
691 /// Whether this is a type-only import (`import type`).
692 pub is_type_only: bool,
693 /// Whether this import originated from a CSS-context.
694 pub from_style: bool,
695 /// Source span of the import declaration.
696 pub span: Span,
697 /// Span of the source string literal used by the LSP to highlight the specifier.
698 pub source_span: Span,
699}
700
701/// How a symbol is imported.
702#[derive(Debug, Clone, PartialEq, Eq)]
703pub enum ImportedName {
704 /// A named import (e.g., `import { foo }`).
705 Named(String),
706 /// A default import (e.g., `import React`).
707 Default,
708 /// A namespace import (e.g., `import * as utils`).
709 Namespace,
710 /// A side-effect import (e.g., `import './styles.css'`).
711 SideEffect,
712}
713
714#[cfg(target_pointer_width = "64")]
715const _: () = assert!(std::mem::size_of::<ExportInfo>() == 112);
716#[cfg(target_pointer_width = "64")]
717const _: () = assert!(std::mem::size_of::<ImportInfo>() == 96);
718#[cfg(target_pointer_width = "64")]
719const _: () = assert!(std::mem::size_of::<ExportName>() == 24);
720#[cfg(target_pointer_width = "64")]
721const _: () = assert!(std::mem::size_of::<ImportedName>() == 24);
722#[cfg(target_pointer_width = "64")]
723const _: () = assert!(std::mem::size_of::<MemberAccess>() == 48);
724#[cfg(target_pointer_width = "64")]
725const _: () = assert!(std::mem::size_of::<SinkSite>() == 64);
726#[cfg(target_pointer_width = "64")]
727const _: () = assert!(std::mem::size_of::<ModuleInfo>() == 720);
728
729/// A re-export declaration.
730#[derive(Debug, Clone)]
731pub struct ReExportInfo {
732 /// The module being re-exported from.
733 pub source: String,
734 /// The name imported from the source module (or `*` for star re-exports).
735 pub imported_name: String,
736 /// The name exported from this module.
737 pub exported_name: String,
738 /// Whether this is a type-only re-export.
739 pub is_type_only: bool,
740 /// Source span of the re-export declaration on this module.
741 pub span: oxc_span::Span,
742}
743
744/// A dynamic `import()` call.
745#[derive(Debug, Clone)]
746pub struct DynamicImportInfo {
747 /// The import specifier.
748 pub source: String,
749 /// Source span of the `import()` expression.
750 pub span: Span,
751 /// Names destructured from the dynamic import result.
752 /// Non-empty means `const { a, b } = await import(...)` -> Named imports.
753 /// Empty means simple `import(...)` or `const x = await import(...)` -> Namespace.
754 pub destructured_names: Vec<String>,
755 /// The local variable name for `const x = await import(...)`.
756 /// Used for namespace import narrowing via member access tracking.
757 pub local_name: Option<String>,
758 /// True when this dynamic import was synthesised by fallow rather than appearing in user source.
759 pub is_speculative: bool,
760}
761
762/// A `require()` call.
763#[derive(Debug, Clone)]
764pub struct RequireCallInfo {
765 /// The require specifier.
766 pub source: String,
767 /// Source span of the `require()` call.
768 pub span: Span,
769 /// Source span of the specifier string-literal argument (including its
770 /// quotes), e.g. the `'./x'` in `require('./x')`. Used to anchor an
771 /// `unresolved-import` diagnostic squiggly under the specifier rather than
772 /// the `require` keyword. `Span::default()` when the argument is not a
773 /// plain string literal.
774 pub source_span: Span,
775 /// Names destructured from the `require()` result.
776 pub destructured_names: Vec<String>,
777 /// The local variable name for `const x = require(...)`.
778 pub local_name: Option<String>,
779}
780
781/// Result of parsing all files, including incremental cache statistics.
782pub struct ParseResult {
783 /// Extracted module information for all successfully parsed files.
784 pub modules: Vec<ModuleInfo>,
785 /// Number of files whose parse results were loaded from cache (unchanged).
786 pub cache_hits: usize,
787 /// Number of files that required a full parse (new or changed).
788 pub cache_misses: usize,
789 /// Summed wall-clock time of the actual AST parses across all rayon workers.
790 pub parse_cpu_ms: f64,
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 #[test]
798 fn line_offsets_empty_string() {
799 assert_eq!(compute_line_offsets(""), vec![0]);
800 }
801
802 #[test]
803 fn sink_shape_bitcode_roundtrip() {
804 for shape in [
805 SinkShape::Call,
806 SinkShape::MemberCall,
807 SinkShape::MemberAssign,
808 SinkShape::TaggedTemplate,
809 SinkShape::JsxAttr,
810 ] {
811 let encoded = bitcode::encode(&shape);
812 let decoded: SinkShape = bitcode::decode(&encoded).expect("decode sink shape");
813 assert_eq!(shape, decoded);
814 }
815 }
816
817 #[test]
818 fn sink_arg_kind_bitcode_roundtrip() {
819 for kind in [
820 SinkArgKind::TemplateWithSubst,
821 SinkArgKind::Concat,
822 SinkArgKind::Object,
823 SinkArgKind::Call,
824 SinkArgKind::Other,
825 ] {
826 let encoded = bitcode::encode(&kind);
827 let decoded: SinkArgKind = bitcode::decode(&encoded).expect("decode sink arg kind");
828 assert_eq!(kind, decoded);
829 }
830 }
831
832 #[test]
833 fn sink_site_bitcode_roundtrip() {
834 let site = SinkSite {
835 sink_shape: SinkShape::MemberAssign,
836 callee_path: "el.innerHTML".to_string(),
837 arg_index: 0,
838 arg_is_non_literal: true,
839 arg_kind: SinkArgKind::Other,
840 arg_idents: vec!["userInput".to_string()],
841 span_start: 10,
842 span_end: 20,
843 };
844 let encoded = bitcode::encode(&site);
845 let decoded: SinkSite = bitcode::decode(&encoded).expect("decode sink site");
846 assert_eq!(decoded.sink_shape, site.sink_shape);
847 assert_eq!(decoded.callee_path, site.callee_path);
848 assert_eq!(decoded.arg_index, site.arg_index);
849 assert_eq!(decoded.arg_is_non_literal, site.arg_is_non_literal);
850 assert_eq!(decoded.arg_kind, site.arg_kind);
851 assert_eq!(decoded.arg_idents, site.arg_idents);
852 assert_eq!(decoded.span(), site.span());
853 }
854
855 #[test]
856 fn line_offsets_single_line_no_newline() {
857 assert_eq!(compute_line_offsets("hello"), vec![0]);
858 }
859
860 #[test]
861 fn line_offsets_single_line_with_newline() {
862 assert_eq!(compute_line_offsets("hello\n"), vec![0, 6]);
863 }
864
865 #[test]
866 fn line_offsets_multiple_lines() {
867 assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
868 }
869
870 #[test]
871 fn line_offsets_trailing_newline() {
872 assert_eq!(compute_line_offsets("abc\ndef\n"), vec![0, 4, 8]);
873 }
874
875 #[test]
876 fn line_offsets_consecutive_newlines() {
877 assert_eq!(compute_line_offsets("\n\n\n"), vec![0, 1, 2, 3]);
878 }
879
880 #[test]
881 fn line_offsets_multibyte_utf8() {
882 assert_eq!(compute_line_offsets("á\n"), vec![0, 3]);
883 }
884
885 #[test]
886 fn line_col_offset_zero() {
887 let offsets = compute_line_offsets("abc\ndef\nghi");
888 let (line, col) = byte_offset_to_line_col(&offsets, 0);
889 assert_eq!((line, col), (1, 0));
890 }
891
892 #[test]
893 fn line_col_middle_of_first_line() {
894 let offsets = compute_line_offsets("abc\ndef\nghi");
895 let (line, col) = byte_offset_to_line_col(&offsets, 2);
896 assert_eq!((line, col), (1, 2));
897 }
898
899 #[test]
900 fn line_col_start_of_second_line() {
901 let offsets = compute_line_offsets("abc\ndef\nghi");
902 let (line, col) = byte_offset_to_line_col(&offsets, 4);
903 assert_eq!((line, col), (2, 0));
904 }
905
906 #[test]
907 fn line_col_middle_of_second_line() {
908 let offsets = compute_line_offsets("abc\ndef\nghi");
909 let (line, col) = byte_offset_to_line_col(&offsets, 5);
910 assert_eq!((line, col), (2, 1));
911 }
912
913 #[test]
914 fn line_col_start_of_third_line() {
915 let offsets = compute_line_offsets("abc\ndef\nghi");
916 let (line, col) = byte_offset_to_line_col(&offsets, 8);
917 assert_eq!((line, col), (3, 0));
918 }
919
920 #[test]
921 fn line_col_end_of_file() {
922 let offsets = compute_line_offsets("abc\ndef\nghi");
923 let (line, col) = byte_offset_to_line_col(&offsets, 10);
924 assert_eq!((line, col), (3, 2));
925 }
926
927 #[test]
928 fn line_col_single_line() {
929 let offsets = compute_line_offsets("hello");
930 let (line, col) = byte_offset_to_line_col(&offsets, 3);
931 assert_eq!((line, col), (1, 3));
932 }
933
934 #[test]
935 fn line_col_at_newline_byte() {
936 let offsets = compute_line_offsets("abc\ndef");
937 let (line, col) = byte_offset_to_line_col(&offsets, 3);
938 assert_eq!((line, col), (1, 3));
939 }
940
941 #[test]
942 fn export_name_matches_str_named() {
943 let name = ExportName::Named("foo".to_string());
944 assert!(name.matches_str("foo"));
945 assert!(!name.matches_str("bar"));
946 assert!(!name.matches_str("default"));
947 }
948
949 #[test]
950 fn export_name_matches_str_default() {
951 let name = ExportName::Default;
952 assert!(name.matches_str("default"));
953 assert!(!name.matches_str("foo"));
954 }
955
956 #[test]
957 fn export_name_display_named() {
958 let name = ExportName::Named("myExport".to_string());
959 assert_eq!(name.to_string(), "myExport");
960 }
961
962 #[test]
963 fn export_name_display_default() {
964 let name = ExportName::Default;
965 assert_eq!(name.to_string(), "default");
966 }
967
968 #[test]
969 fn export_name_equality_named() {
970 let a = ExportName::Named("foo".to_string());
971 let b = ExportName::Named("foo".to_string());
972 let c = ExportName::Named("bar".to_string());
973 assert_eq!(a, b);
974 assert_ne!(a, c);
975 }
976
977 #[test]
978 fn export_name_equality_default() {
979 let a = ExportName::Default;
980 let b = ExportName::Default;
981 assert_eq!(a, b);
982 }
983
984 #[test]
985 fn export_name_named_not_equal_to_default() {
986 let named = ExportName::Named("default".to_string());
987 let default = ExportName::Default;
988 assert_ne!(named, default);
989 }
990
991 #[test]
992 fn export_name_hash_consistency() {
993 use std::collections::hash_map::DefaultHasher;
994 use std::hash::{Hash, Hasher};
995
996 let mut h1 = DefaultHasher::new();
997 let mut h2 = DefaultHasher::new();
998 ExportName::Named("foo".to_string()).hash(&mut h1);
999 ExportName::Named("foo".to_string()).hash(&mut h2);
1000 assert_eq!(h1.finish(), h2.finish());
1001 }
1002
1003 #[test]
1004 fn export_name_matches_str_empty_string() {
1005 let name = ExportName::Named(String::new());
1006 assert!(name.matches_str(""));
1007 assert!(!name.matches_str("foo"));
1008 }
1009
1010 #[test]
1011 fn export_name_default_does_not_match_empty() {
1012 let name = ExportName::Default;
1013 assert!(!name.matches_str(""));
1014 }
1015
1016 #[test]
1017 fn imported_name_equality() {
1018 assert_eq!(
1019 ImportedName::Named("foo".to_string()),
1020 ImportedName::Named("foo".to_string())
1021 );
1022 assert_ne!(
1023 ImportedName::Named("foo".to_string()),
1024 ImportedName::Named("bar".to_string())
1025 );
1026 assert_eq!(ImportedName::Default, ImportedName::Default);
1027 assert_eq!(ImportedName::Namespace, ImportedName::Namespace);
1028 assert_eq!(ImportedName::SideEffect, ImportedName::SideEffect);
1029 assert_ne!(ImportedName::Default, ImportedName::Namespace);
1030 assert_ne!(
1031 ImportedName::Named("default".to_string()),
1032 ImportedName::Default
1033 );
1034 }
1035
1036 #[test]
1037 fn member_kind_equality() {
1038 assert_eq!(MemberKind::EnumMember, MemberKind::EnumMember);
1039 assert_eq!(MemberKind::ClassMethod, MemberKind::ClassMethod);
1040 assert_eq!(MemberKind::ClassProperty, MemberKind::ClassProperty);
1041 assert_eq!(MemberKind::NamespaceMember, MemberKind::NamespaceMember);
1042 assert_ne!(MemberKind::EnumMember, MemberKind::ClassMethod);
1043 assert_ne!(MemberKind::ClassMethod, MemberKind::ClassProperty);
1044 assert_ne!(MemberKind::NamespaceMember, MemberKind::EnumMember);
1045 }
1046
1047 #[test]
1048 fn member_kind_bitcode_roundtrip() {
1049 let kinds = [
1050 MemberKind::EnumMember,
1051 MemberKind::ClassMethod,
1052 MemberKind::ClassProperty,
1053 MemberKind::NamespaceMember,
1054 ];
1055 for kind in &kinds {
1056 let bytes = bitcode::encode(kind);
1057 let decoded: MemberKind = bitcode::decode(&bytes).unwrap();
1058 assert_eq!(&decoded, kind);
1059 }
1060 }
1061
1062 #[test]
1063 fn member_access_bitcode_roundtrip() {
1064 let access = MemberAccess {
1065 object: "Status".to_string(),
1066 member: "Active".to_string(),
1067 };
1068 let bytes = bitcode::encode(&access);
1069 let decoded: MemberAccess = bitcode::decode(&bytes).unwrap();
1070 assert_eq!(decoded.object, "Status");
1071 assert_eq!(decoded.member, "Active");
1072 }
1073
1074 #[test]
1075 fn line_offsets_crlf_only_counts_lf() {
1076 let offsets = compute_line_offsets("ab\r\ncd");
1077 assert_eq!(offsets, vec![0, 4]);
1078 }
1079
1080 #[test]
1081 fn line_col_empty_file_offset_zero() {
1082 let offsets = compute_line_offsets("");
1083 let (line, col) = byte_offset_to_line_col(&offsets, 0);
1084 assert_eq!((line, col), (1, 0));
1085 }
1086
1087 #[test]
1088 fn function_complexity_bitcode_roundtrip() {
1089 let fc = FunctionComplexity {
1090 name: "processData".to_string(),
1091 line: 42,
1092 col: 4,
1093 cyclomatic: 15,
1094 cognitive: 25,
1095 line_count: 80,
1096 param_count: 3,
1097 source_hash: Some("0123456789abcdef".to_string()),
1098 contributions: vec![
1099 ComplexityContribution {
1100 line: 43,
1101 col: 8,
1102 metric: ComplexityMetric::Cyclomatic,
1103 kind: ComplexityContributionKind::If,
1104 weight: 1,
1105 nesting: 0,
1106 },
1107 ComplexityContribution {
1108 line: 45,
1109 col: 12,
1110 metric: ComplexityMetric::Cognitive,
1111 kind: ComplexityContributionKind::ElseIf,
1112 weight: 3,
1113 nesting: 2,
1114 },
1115 ],
1116 };
1117 let bytes = bitcode::encode(&fc);
1118 let decoded: FunctionComplexity = bitcode::decode(&bytes).unwrap();
1119 assert_eq!(decoded.name, "processData");
1120 assert_eq!(decoded.line, 42);
1121 assert_eq!(decoded.col, 4);
1122 assert_eq!(decoded.cyclomatic, 15);
1123 assert_eq!(decoded.cognitive, 25);
1124 assert_eq!(decoded.line_count, 80);
1125 assert_eq!(decoded.source_hash.as_deref(), Some("0123456789abcdef"));
1126 assert_eq!(decoded.contributions.len(), 2);
1127 assert_eq!(
1128 decoded.contributions[1].kind,
1129 ComplexityContributionKind::ElseIf
1130 );
1131 assert_eq!(decoded.contributions[1].weight, 3);
1132 assert_eq!(decoded.contributions[1].nesting, 2);
1133 assert_eq!(decoded.contributions[1].metric, ComplexityMetric::Cognitive);
1134 }
1135}