Skip to main content

cuke_dedup/
source_adapter.rs

1//! Definition-source adapter contracts and extension-based routing.
2
3mod config;
4mod finalization;
5pub(crate) use config::FrontendConfig;
6pub(crate) mod semantics;
7pub(crate) mod suppression;
8pub use finalization::{
9    SourceAdvisory, SourceFinalization, SourceUncertainty, UncertaintyCause, UncertaintyScope,
10};
11
12use crate::model::{SourceLocation, StepDefinition};
13use crate::resource_limits::{read_utf8, MAX_PROJECT_INPUT_BYTES};
14use anyhow::{Context, Result};
15use std::any::{Any, TypeId};
16use std::collections::HashMap;
17use std::panic::{RefUnwindSafe, UnwindSafe};
18use std::path::{Path, PathBuf};
19
20pub(crate) const UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX: &str =
21    "unresolved step-registration calls:";
22
23/// Prefix of the diagnostic emitted when a source file cannot be parsed as valid JavaScript or
24/// TypeScript. A parse failure hides every definition the file would have contributed, so it is a
25/// completeness signal; `--fail-on-unparseable` promotes it to a hard error.
26pub(crate) const UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX: &str =
27    "source contains JavaScript/TypeScript syntax errors";
28
29/// A recognized registration cannot contribute a statically known matcher.
30pub(crate) const UNSUPPORTED_MATCHER_DIAGNOSTIC: &str =
31    "dynamic or unsupported step matcher cannot be analyzed statically";
32pub(crate) const INVALID_MATCHER_DIAGNOSTIC: &str =
33    "invalid JavaScript escape sequence in step matcher";
34
35/// Parser language selected for a definition source.
36#[non_exhaustive]
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SourceLanguage {
39    /// Ruby Cucumber sources selected explicitly by definition globs.
40    Ruby,
41    /// JavaScript, JSX, or their module variants.
42    JavaScript,
43    /// TypeScript or its module variants.
44    TypeScript,
45    /// TypeScript with JSX syntax.
46    Tsx,
47}
48
49/// Returns the tree-sitter grammar registered for a definition-source language.
50pub(crate) fn grammar_for_language(language: SourceLanguage) -> tree_sitter::Language {
51    match language {
52        SourceLanguage::Ruby => tree_sitter_ruby::LANGUAGE.into(),
53        SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
54        SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
55        SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
56    }
57}
58
59/// Frontend-owned reason for excluding a discovered source before extraction.
60#[non_exhaustive]
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SourceExclusion {
63    /// A compressed container prefix.
64    Compressed,
65    /// A binary-source prefix.
66    Binary,
67    /// Generated or minified source geometry.
68    Minified,
69}
70
71impl SourceExclusion {
72    /// Stable diagnostic label for this exclusion.
73    pub fn as_str(self) -> &'static str {
74        match self {
75            Self::Compressed => "compressed",
76            Self::Binary => "binary",
77            Self::Minified => "minified",
78        }
79    }
80}
81
82/// A discovered definition source and the language used to parse it.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct SourceFile {
85    /// Absolute source path.
86    pub path: PathBuf,
87    /// Parser language inferred from the registered extension.
88    pub language: SourceLanguage,
89}
90
91/// Impact of a source-extraction diagnostic.
92#[non_exhaustive]
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum ExtractionDiagnosticLevel {
95    /// Analysis continues, but a static-analysis limitation applies.
96    Warning,
97    /// The source contains a malformed step registration.
98    Error,
99}
100
101/// Machine-readable effect of an extraction diagnostic, independent of its wording.
102#[non_exhaustive]
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum ExtractionDiagnosticKind {
105    /// Compatibility mode used by [`ExtractionDiagnostic::new`]; classify legacy message prefixes.
106    LegacyMessage,
107    /// A source load whose target has not been included in the source graph.
108    Dependency,
109    /// An advisory or error that does not itself signal missing definitions.
110    Other,
111    /// Static extraction may have missed definitions.
112    Incomplete,
113    /// Source syntax could not be fully parsed; also signals incomplete extraction.
114    Unparseable,
115}
116
117/// A source-localized problem encountered while extracting definitions.
118#[non_exhaustive]
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ExtractionDiagnostic {
121    /// Machine-readable completeness and parse status.
122    pub kind: ExtractionDiagnosticKind,
123    /// Diagnostic impact.
124    pub level: ExtractionDiagnosticLevel,
125    /// Source position of the affected matcher.
126    pub location: SourceLocation,
127    /// Human-readable explanation.
128    pub message: String,
129}
130
131impl ExtractionDiagnostic {
132    /// Creates a diagnostic with legacy message-prefix classification.
133    ///
134    /// New adapters should use [`Self::with_kind`] to classify diagnostics independently of text.
135    pub fn new(
136        level: ExtractionDiagnosticLevel,
137        location: SourceLocation,
138        message: impl Into<String>,
139    ) -> Self {
140        Self::with_kind(
141            ExtractionDiagnosticKind::LegacyMessage,
142            level,
143            location,
144            message,
145        )
146    }
147
148    /// Creates an explicitly classified diagnostic without inferring its effect from its message.
149    pub fn with_kind(
150        kind: ExtractionDiagnosticKind,
151        level: ExtractionDiagnosticLevel,
152        location: SourceLocation,
153        message: impl Into<String>,
154    ) -> Self {
155        Self {
156            kind,
157            level,
158            location,
159            message: message.into(),
160        }
161    }
162}
163
164/// Whether `diagnostic` signals possibly missed definitions: kinds `Incomplete`, `Unparseable` and
165/// `Dependency`. A `LegacyMessage` diagnostic is classified by its message text.
166pub(crate) fn is_completeness_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
167    match diagnostic.kind {
168        ExtractionDiagnosticKind::Incomplete
169        | ExtractionDiagnosticKind::Unparseable
170        | ExtractionDiagnosticKind::Dependency => true,
171        ExtractionDiagnosticKind::Other => false,
172        ExtractionDiagnosticKind::LegacyMessage => {
173            diagnostic
174                .message
175                .starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
176                || matches!(
177                    diagnostic.message.as_str(),
178                    UNSUPPORTED_MATCHER_DIAGNOSTIC | INVALID_MATCHER_DIAGNOSTIC
179                )
180                || is_unparseable_diagnostic(diagnostic)
181        }
182    }
183}
184
185/// Reports a parse failure separately from other causes of incomplete extraction.
186pub(crate) fn is_unparseable_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
187    match diagnostic.kind {
188        ExtractionDiagnosticKind::Unparseable => true,
189        ExtractionDiagnosticKind::LegacyMessage => diagnostic
190            .message
191            .starts_with(UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX),
192        ExtractionDiagnosticKind::Other
193        | ExtractionDiagnosticKind::Incomplete
194        | ExtractionDiagnosticKind::Dependency => false,
195    }
196}
197
198/// A statically resolved source-loading edge.
199#[non_exhaustive]
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct SourceDependency {
202    /// Source location of the loading call.
203    pub location: SourceLocation,
204    /// Canonical source included by that call.
205    pub target: PathBuf,
206    /// True only when discovery proves this target was not independently selected.
207    pub dependency_only: bool,
208}
209
210impl SourceDependency {
211    /// Supplies explicit dependency-only origin evidence; unknown origins must remain false.
212    pub fn with_dependency_only_target(mut self, dependency_only: bool) -> Self {
213        self.dependency_only = dependency_only;
214        self
215    }
216
217    /// Records a loading call and its resolved source.
218    pub fn new(location: SourceLocation, target: PathBuf) -> Self {
219        Self {
220            location,
221            target,
222            dependency_only: false,
223        }
224    }
225}
226
227/// A source-loading call that discovery deliberately did not follow, with the reason.
228///
229/// Extraction keeps the call's dependency diagnostic, so analysis stays incomplete; the reason
230/// replaces the generic "unresolved" wording at that location.
231#[non_exhaustive]
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct DependencyGap {
234    /// Source location of the loading call.
235    pub location: SourceLocation,
236    /// Why the target was not admitted, such as an exceeded resource limit.
237    pub reason: String,
238}
239
240impl DependencyGap {
241    /// Records a loading call that discovery refused to follow.
242    pub fn new(location: SourceLocation, reason: impl Into<String>) -> Self {
243        Self {
244            location,
245            reason: reason.into(),
246        }
247    }
248}
249
250/// Potential Ruby step invocations, separate from concrete Gherkin execution evidence.
251#[non_exhaustive]
252#[derive(Debug, Clone, Default, PartialEq, Eq)]
253pub struct IndirectStepUsage {
254    /// Statically decoded possible step texts, including deferred calls.
255    pub texts: std::collections::BTreeSet<String>,
256    /// An unresolved invocation prevents proving any Ruby definition unused.
257    pub unknown: bool,
258}
259
260impl IndirectStepUsage {
261    /// Combines source metadata; absent metadata is conservatively unknown.
262    pub fn extend(&mut self, other: Option<Self>) {
263        match other {
264            Some(other) => {
265                self.texts.extend(other.texts);
266                self.unknown |= other.unknown;
267            }
268            None => self.unknown = true,
269        }
270    }
271}
272
273/// A source-declared parameter type, resolved without executing its transformer.
274#[non_exhaustive]
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct SourceParameterType {
277    /// Literal registry name, or unknown when static name resolution failed.
278    pub name: Option<String>,
279    /// Validated Rust-compatible matching expression; absent for an unresolved declaration.
280    pub expression: Option<String>,
281    /// Location of the declaration, used to diagnose collisions across files.
282    pub location: SourceLocation,
283}
284
285impl SourceParameterType {
286    /// Constructs source metadata; unresolved names or expressions must retain uncertainty.
287    pub fn new(name: Option<String>, expression: Option<String>, location: SourceLocation) -> Self {
288        Self {
289            name,
290            expression,
291            location,
292        }
293    }
294}
295
296/// Definitions and non-fatal diagnostics extracted from one source.
297#[non_exhaustive]
298#[derive(Debug, Clone, Default, PartialEq)]
299pub struct Extraction {
300    /// Successfully extracted definitions.
301    pub definitions: Vec<StepDefinition>,
302    /// Parameter declarations; merge the complete source registry before matching expressions.
303    pub parameter_types: Vec<SourceParameterType>,
304    /// Optional indirect usage metadata; merge across every selected Ruby source.
305    pub indirect_usage: Option<IndirectStepUsage>,
306    /// Problems that did not prevent extraction of the rest of the file.
307    pub diagnostics: Vec<ExtractionDiagnostic>,
308}
309
310impl Extraction {
311    /// Creates an extraction result from definitions and non-fatal diagnostics.
312    pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
313        Self {
314            definitions,
315            diagnostics,
316            indirect_usage: None,
317            parameter_types: Vec::new(),
318        }
319    }
320}
321
322/// Reusable state for extracting multiple definition sources in one analysis run.
323///
324/// Reusing a session shares bounded parser and module-resolution work across files. Create one
325/// session per analysis root rather than sharing it between unrelated repositories.
326#[derive(Default)]
327pub struct SourceExtractionSession {
328    dependencies: std::collections::BTreeMap<(PathBuf, usize, usize), SourceDependency>,
329    dependency_gaps: std::collections::BTreeMap<(PathBuf, usize, usize), String>,
330    root: Option<PathBuf>,
331    registrations: Vec<String>,
332    assertion_modules: Vec<String>,
333    states: HashMap<TypeId, Box<dyn Any + Send + Sync + UnwindSafe + RefUnwindSafe>>,
334}
335
336/// Backend-owned state; related syntax variants can share the same state type.
337/// The bounds preserve the public session's existing thread and unwind-safety guarantees.
338pub(crate) trait AdapterSessionState:
339    Any + Send + Sync + UnwindSafe + RefUnwindSafe
340{
341    /// Builds the backend's per-run state from the session root (`None` for a default session),
342    /// configured registrations and assertion modules. Infallible; called at most once per state
343    /// type per session, at construction for a rooted session and on first access otherwise.
344    fn initialize(
345        root: Option<&Path>,
346        registrations: &[String],
347        assertion_modules: &[String],
348    ) -> Self;
349}
350
351/// Internal stateful registrations must explicitly select their backend state.
352pub(crate) trait StatefulSourceAdapter: SourceAdapter {
353    type State: AdapterSessionState;
354    /// Adds loaded sources, resolved edges and refused-load gaps to `files`, without executing them.
355    /// Called once per adapter name when a selected definition has its language; whole-graph
356    /// failures go into `files.errors`. The default does nothing.
357    fn discover_dependencies(
358        _config: &crate::config::Config,
359        _excludes: &globset::GlobSet,
360        _files: &mut crate::discovery::DiscoveredFiles,
361    ) {
362    }
363}
364
365impl SourceExtractionSession {
366    /// Supplies resolved loading edges from discovery without executing dependencies.
367    pub fn with_dependencies(mut self, dependencies: &[SourceDependency]) -> Self {
368        self.dependencies.extend(dependencies.iter().map(|edge| {
369            (
370                (
371                    edge.location.path.clone(),
372                    edge.location.line,
373                    edge.location.column,
374                ),
375                edge.clone(),
376            )
377        }));
378        self
379    }
380
381    /// Finalizes all selected frontends after extraction and applies cross-file trust effects.
382    ///
383    /// Call this before analyzing accumulated definitions. A later source can invalidate an
384    /// earlier registration; individual extraction results are provisional until this operation.
385    pub fn finalize(
386        &mut self,
387        files: &[SourceFile],
388        definitions: &mut Vec<StepDefinition>,
389    ) -> Result<SourceFinalization> {
390        let mut result = SourceFinalization::default();
391        let mut completed = std::collections::BTreeSet::new();
392        for file in files {
393            let adapter = adapter_for_language(file.language);
394            if completed.insert(adapter.name()) {
395                let finalization = adapter.finalize_session(self)?;
396                result.uncertainties.extend(finalization.uncertainties);
397                result.advisories.extend(finalization.advisories);
398            }
399        }
400        result.apply(definitions);
401        Ok(result)
402    }
403
404    /// Supplies loading calls that discovery refused to follow, so their diagnostics name the cause.
405    pub fn with_dependency_gaps(mut self, gaps: &[DependencyGap]) -> Self {
406        self.dependency_gaps.extend(gaps.iter().map(|gap| {
407            (
408                (
409                    gap.location.path.clone(),
410                    gap.location.line,
411                    gap.location.column,
412                ),
413                gap.reason.clone(),
414            )
415        }));
416        self
417    }
418
419    /// The reason discovery refused to follow the loading call at `location`, if it did.
420    pub(crate) fn dependency_gap(&self, location: &SourceLocation) -> Option<&str> {
421        self.dependency_gaps
422            .get(&(location.path.clone(), location.line, location.column))
423            .map(String::as_str)
424    }
425
426    /// Whether discovery supplied a resolved edge for the loading call at `location`, matched by
427    /// path, start line and start column only.
428    pub(crate) fn dependency_resolved(&self, location: &SourceLocation) -> bool {
429        self.dependencies
430            .contains_key(&(location.path.clone(), location.line, location.column))
431    }
432
433    /// Clones of the resolved loading edges, ordered by call path, line and column.
434    pub(crate) fn dependency_edges(&self) -> Vec<SourceDependency> {
435        self.dependencies.values().cloned().collect()
436    }
437
438    /// Creates an extraction session whose imported modules must remain inside `root`.
439    pub fn new(root: &Path) -> Self {
440        Self::with_registrations(root, &[])
441    }
442
443    /// Creates a session that also treats `registrations` as step-registration function names.
444    ///
445    /// Use this for project-declared wrappers that static inference cannot recognize, such as a
446    /// helper that builds its registration call dynamically.
447    pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
448        Self::with_options(root, registrations, &[])
449    }
450
451    /// Creates a session that also trusts `assertion_modules` as assertion-factory providers.
452    ///
453    /// Use this for a local module that re-exports `expect` from a supported assertion package, or
454    /// for a runner this build does not recognize by name. Without it such a factory is untrusted
455    /// and its expected values are not treated as behaviour.
456    pub fn with_options(
457        root: &Path,
458        registrations: &[String],
459        assertion_modules: &[String],
460    ) -> Self {
461        let mut session = Self {
462            root: Some(root.to_owned()),
463            registrations: registrations.to_vec(),
464            assertion_modules: assertion_modules.to_vec(),
465            ..Self::default()
466        };
467        // Preserve construction-time root resolution, including a failed canonicalization.
468        for registration in SOURCE_ADAPTER_REGISTRY {
469            if let Some(initialize) = registration.initialize_session {
470                initialize(&mut session);
471            }
472        }
473        session
474    }
475
476    /// Creates the `T` state for this session unless one already exists for that type.
477    pub(crate) fn initialize<T: AdapterSessionState>(&mut self) {
478        self.states.entry(TypeId::of::<T>()).or_insert_with(|| {
479            Box::new(T::initialize(
480                self.root.as_deref(),
481                &self.registrations,
482                &self.assertion_modules,
483            ))
484        });
485    }
486
487    /// Returns this session's `T` state. A session without a root creates it on first access; a
488    /// rooted session errors when `T` was not initialized at construction. Also errors when the
489    /// stored value is not a `T`.
490    pub(crate) fn state<T: AdapterSessionState>(&mut self) -> Result<&mut T> {
491        // Explicit-root sessions must never establish a boundary later than construction.
492        if self.root.is_none() {
493            self.initialize::<T>();
494        }
495        self.states
496            .get_mut(&TypeId::of::<T>())
497            .and_then(|state| {
498                let state: &mut dyn Any = state.as_mut();
499                state.downcast_mut::<T>()
500            })
501            .with_context(|| {
502                format!(
503                    "source adapter state {} is uninitialized or incompatible",
504                    std::any::type_name::<T>()
505                )
506            })
507    }
508}
509
510/// Converts one supported definition-source language into the shared definition IR.
511pub trait SourceAdapter: Sync {
512    /// Stable adapter name used in diagnostics and registry inspection.
513    fn name(&self) -> &'static str;
514
515    /// Parser language produced by this adapter registration.
516    fn language(&self) -> SourceLanguage;
517
518    /// Extracts definitions from in-memory source using `file` for source locations.
519    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
520
521    /// Whether this frontend participates when no definition globs are configured.
522    fn discover_by_default(&self) -> bool {
523        true
524    }
525
526    /// Classifies a source using this frontend's bounded input-filter policy.
527    fn inspect_source(
528        &self,
529        _file: &SourceFile,
530        _registrations: &[String],
531    ) -> Option<SourceExclusion> {
532        None
533    }
534
535    /// Whether missing source metadata or reads leave indirect step usage unknown.
536    fn supports_indirect_usage(&self) -> bool {
537        false
538    }
539
540    /// Resolves source parameter declarations without granting unknown matcher authority.
541    fn resolve_parameter_types(
542        &self,
543        declarations: Vec<SourceParameterType>,
544        configured: &std::collections::BTreeMap<String, String>,
545    ) -> (std::collections::BTreeMap<String, String>, Vec<String>) {
546        if declarations.is_empty() {
547            (configured.clone(), Vec::new())
548        } else {
549            (
550                Default::default(),
551                vec!["frontend does not support source parameter declarations".into()],
552            )
553        }
554    }
555
556    /// Prepares selected-source evidence before extraction, without executing source code.
557    fn prepare_session(
558        &self,
559        _files: &[SourceFile],
560        _session: &mut SourceExtractionSession,
561    ) -> Result<()> {
562        Ok(())
563    }
564
565    /// Reports frontend-owned trust effects after every selected source has been extracted.
566    fn finalize_session(
567        &self,
568        _session: &mut SourceExtractionSession,
569    ) -> Result<SourceFinalization> {
570        Ok(SourceFinalization::default())
571    }
572
573    /// Extracts definitions while sharing bounded state with other files in the same run.
574    fn extract_with_session(
575        &self,
576        source: &str,
577        file: &SourceFile,
578        _session: &mut SourceExtractionSession,
579    ) -> Result<Extraction> {
580        self.extract(source, file)
581    }
582
583    /// Reads and extracts one source file.
584    fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
585        let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
586        self.extract(&source, file)
587    }
588
589    /// Reads and extracts one source file using shared run state.
590    fn extract_file_with_session(
591        &self,
592        file: &SourceFile,
593        session: &mut SourceExtractionSession,
594    ) -> Result<Extraction> {
595        let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
596        self.extract_with_session(&source, file, session)
597    }
598}
599
600type DependencyDiscovery =
601    fn(&crate::config::Config, &globset::GlobSet, &mut crate::discovery::DiscoveredFiles);
602
603/// One suffix-to-adapter registration.
604#[non_exhaustive]
605#[derive(Clone, Copy)]
606pub struct SourceAdapterRegistration {
607    /// Filename suffix, including the leading dot.
608    pub suffix: &'static str,
609    /// Adapter responsible for matching sources.
610    pub adapter: &'static dyn SourceAdapter,
611    initialize_session: Option<fn(&mut SourceExtractionSession)>,
612    discover_dependencies: Option<DependencyDiscovery>,
613}
614
615impl SourceAdapterRegistration {
616    /// Creates one suffix-to-adapter registration.
617    pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
618        Self {
619            suffix,
620            adapter,
621            initialize_session: None,
622            discover_dependencies: None,
623        }
624    }
625
626    /// Creates a registration for `adapter` carrying its session-state initializer and its
627    /// `discover_dependencies` hook, which may be the no-op default.
628    const fn with_session<A: StatefulSourceAdapter>(
629        suffix: &'static str,
630        adapter: &'static A,
631    ) -> Self {
632        Self {
633            suffix,
634            adapter,
635            initialize_session: Some(SourceExtractionSession::initialize::<A::State>),
636            discover_dependencies: Some(A::discover_dependencies),
637        }
638    }
639}
640
641/// The authoritative language/adapter mapping: one table drives both the suffix registry and the
642/// language lookup, so the two routing surfaces cannot drift apart. Adding a language means one
643/// row here; the expansion stays an exhaustive match, so routing remains total without a
644/// fallible lookup.
645macro_rules! register_source_adapters {
646    ( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
647        /// Registered definition-source suffixes in deterministic lookup order.
648        pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
649            $(
650                $(
651                    SourceAdapterRegistration::with_session($suffix, &$adapter),
652                )+
653            )+
654        ];
655
656        /// Returns the adapter for an already classified source language.
657        pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
658            match language {
659                $(SourceLanguage::$language => &$adapter,)+
660            }
661        }
662    };
663}
664
665register_source_adapters! {
666    Ruby => crate::ruby::RUBY_ADAPTER : ".rb";
667    JavaScript => crate::typescript::JAVASCRIPT_ADAPTER : ".mjs", ".cjs", ".jsx", ".js";
668    TypeScript => crate::typescript::TYPESCRIPT_ADAPTER : ".mts", ".cts", ".ts";
669    Tsx => crate::typescript::TSX_ADAPTER : ".tsx";
670}
671
672/// Returns the adapter registered for `path`, rejecting declaration and source-map files.
673pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
674    let extension = path.extension()?.to_str()?;
675    if extension == "map" || is_declaration_file(path, extension) {
676        return None;
677    }
678    SOURCE_ADAPTER_REGISTRY
679        .iter()
680        .find(|registration| extension == registration.suffix.trim_start_matches('.'))
681        .map(|registration| registration.adapter)
682}
683
684/// Whether `path` is a TypeScript declaration file: extension `ts`, `mts` or `cts` and a stem that
685/// is `.d` or ends in `.d`. Comparisons are case-sensitive.
686fn is_declaration_file(path: &Path, extension: &str) -> bool {
687    matches!(extension, "ts" | "mts" | "cts")
688        && path.file_stem().is_some_and(|stem| {
689            stem == ".d"
690                || Path::new(stem)
691                    .extension()
692                    .is_some_and(|extension| extension == "d")
693        })
694}
695
696/// Classifies a path using the registered suffixes.
697pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
698    adapter_for_path(path).map(SourceAdapter::language)
699}
700
701/// Returns the registered suffixes for tooling and adapter conformance tests.
702pub fn registered_suffixes() -> Vec<&'static str> {
703    SOURCE_ADAPTER_REGISTRY
704        .iter()
705        .map(|registration| registration.suffix)
706        .collect()
707}
708
709#[cfg(test)]
710mod tests;
711
712/// Runs each selected frontend's internal dependency policy once.
713pub(crate) fn discover_dependencies(
714    config: &crate::config::Config,
715    excludes: &globset::GlobSet,
716    files: &mut crate::discovery::DiscoveredFiles,
717) {
718    let mut completed = std::collections::BTreeSet::new();
719    let callbacks: Vec<_> = SOURCE_ADAPTER_REGISTRY
720        .iter()
721        .filter(|entry| {
722            files
723                .definitions
724                .iter()
725                .any(|file| file.language == entry.adapter.language())
726        })
727        .filter(|entry| completed.insert(entry.adapter.name()))
728        .filter_map(|entry| entry.discover_dependencies)
729        .collect();
730    for callback in callbacks {
731        callback(config, excludes, files);
732    }
733}