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
164pub(crate) fn is_completeness_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
165    match diagnostic.kind {
166        ExtractionDiagnosticKind::Incomplete
167        | ExtractionDiagnosticKind::Unparseable
168        | ExtractionDiagnosticKind::Dependency => true,
169        ExtractionDiagnosticKind::Other => false,
170        ExtractionDiagnosticKind::LegacyMessage => {
171            diagnostic
172                .message
173                .starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
174                || matches!(
175                    diagnostic.message.as_str(),
176                    UNSUPPORTED_MATCHER_DIAGNOSTIC | INVALID_MATCHER_DIAGNOSTIC
177                )
178                || is_unparseable_diagnostic(diagnostic)
179        }
180    }
181}
182
183/// Reports a parse failure separately from other causes of incomplete extraction.
184pub(crate) fn is_unparseable_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
185    match diagnostic.kind {
186        ExtractionDiagnosticKind::Unparseable => true,
187        ExtractionDiagnosticKind::LegacyMessage => diagnostic
188            .message
189            .starts_with(UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX),
190        ExtractionDiagnosticKind::Other
191        | ExtractionDiagnosticKind::Incomplete
192        | ExtractionDiagnosticKind::Dependency => false,
193    }
194}
195
196/// A statically resolved source-loading edge.
197#[non_exhaustive]
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct SourceDependency {
200    /// Source location of the loading call.
201    pub location: SourceLocation,
202    /// Canonical source included by that call.
203    pub target: PathBuf,
204    /// True only when discovery proves this target was not independently selected.
205    pub dependency_only: bool,
206}
207
208impl SourceDependency {
209    /// Supplies explicit dependency-only origin evidence; unknown origins must remain false.
210    pub fn with_dependency_only_target(mut self, dependency_only: bool) -> Self {
211        self.dependency_only = dependency_only;
212        self
213    }
214
215    /// Records a loading call and its resolved source.
216    pub fn new(location: SourceLocation, target: PathBuf) -> Self {
217        Self {
218            location,
219            target,
220            dependency_only: false,
221        }
222    }
223}
224
225/// A source-loading call that discovery deliberately did not follow, with the reason.
226///
227/// Extraction keeps the call's dependency diagnostic, so analysis stays incomplete; the reason
228/// replaces the generic "unresolved" wording at that location.
229#[non_exhaustive]
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct DependencyGap {
232    /// Source location of the loading call.
233    pub location: SourceLocation,
234    /// Why the target was not admitted, such as an exceeded resource limit.
235    pub reason: String,
236}
237
238impl DependencyGap {
239    /// Records a loading call that discovery refused to follow.
240    pub fn new(location: SourceLocation, reason: impl Into<String>) -> Self {
241        Self {
242            location,
243            reason: reason.into(),
244        }
245    }
246}
247
248/// Potential Ruby step invocations, separate from concrete Gherkin execution evidence.
249#[non_exhaustive]
250#[derive(Debug, Clone, Default, PartialEq, Eq)]
251pub struct IndirectStepUsage {
252    /// Statically decoded possible step texts, including deferred calls.
253    pub texts: std::collections::BTreeSet<String>,
254    /// An unresolved invocation prevents proving any Ruby definition unused.
255    pub unknown: bool,
256}
257
258impl IndirectStepUsage {
259    /// Combines source metadata; absent metadata is conservatively unknown.
260    pub fn extend(&mut self, other: Option<Self>) {
261        match other {
262            Some(other) => {
263                self.texts.extend(other.texts);
264                self.unknown |= other.unknown;
265            }
266            None => self.unknown = true,
267        }
268    }
269}
270
271/// A source-declared parameter type, resolved without executing its transformer.
272#[non_exhaustive]
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct SourceParameterType {
275    /// Literal registry name, or unknown when static name resolution failed.
276    pub name: Option<String>,
277    /// Validated Rust-compatible matching expression; absent for an unresolved declaration.
278    pub expression: Option<String>,
279    /// Location of the declaration, used to diagnose collisions across files.
280    pub location: SourceLocation,
281}
282
283impl SourceParameterType {
284    /// Constructs source metadata; unresolved names or expressions must retain uncertainty.
285    pub fn new(name: Option<String>, expression: Option<String>, location: SourceLocation) -> Self {
286        Self {
287            name,
288            expression,
289            location,
290        }
291    }
292}
293
294/// Definitions and non-fatal diagnostics extracted from one source.
295#[non_exhaustive]
296#[derive(Debug, Clone, Default, PartialEq)]
297pub struct Extraction {
298    /// Successfully extracted definitions.
299    pub definitions: Vec<StepDefinition>,
300    /// Parameter declarations; merge the complete source registry before matching expressions.
301    pub parameter_types: Vec<SourceParameterType>,
302    /// Optional indirect usage metadata; merge across every selected Ruby source.
303    pub indirect_usage: Option<IndirectStepUsage>,
304    /// Problems that did not prevent extraction of the rest of the file.
305    pub diagnostics: Vec<ExtractionDiagnostic>,
306}
307
308impl Extraction {
309    /// Creates an extraction result from definitions and non-fatal diagnostics.
310    pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
311        Self {
312            definitions,
313            diagnostics,
314            indirect_usage: None,
315            parameter_types: Vec::new(),
316        }
317    }
318}
319
320/// Reusable state for extracting multiple definition sources in one analysis run.
321///
322/// Reusing a session shares bounded parser and module-resolution work across files. Create one
323/// session per analysis root rather than sharing it between unrelated repositories.
324#[derive(Default)]
325pub struct SourceExtractionSession {
326    dependencies: std::collections::BTreeMap<(PathBuf, usize, usize), SourceDependency>,
327    dependency_gaps: std::collections::BTreeMap<(PathBuf, usize, usize), String>,
328    root: Option<PathBuf>,
329    registrations: Vec<String>,
330    assertion_modules: Vec<String>,
331    states: HashMap<TypeId, Box<dyn Any + Send + Sync + UnwindSafe + RefUnwindSafe>>,
332}
333
334/// Backend-owned state; related syntax variants can share the same state type.
335/// The bounds preserve the public session's existing thread and unwind-safety guarantees.
336pub(crate) trait AdapterSessionState:
337    Any + Send + Sync + UnwindSafe + RefUnwindSafe
338{
339    fn initialize(
340        root: Option<&Path>,
341        registrations: &[String],
342        assertion_modules: &[String],
343    ) -> Self;
344}
345
346/// Internal stateful registrations must explicitly select their backend state.
347pub(crate) trait StatefulSourceAdapter: SourceAdapter {
348    type State: AdapterSessionState;
349    fn discover_dependencies(
350        _config: &crate::config::Config,
351        _excludes: &globset::GlobSet,
352        _files: &mut crate::discovery::DiscoveredFiles,
353    ) {
354    }
355}
356
357impl SourceExtractionSession {
358    /// Supplies resolved loading edges from discovery without executing dependencies.
359    pub fn with_dependencies(mut self, dependencies: &[SourceDependency]) -> Self {
360        self.dependencies.extend(dependencies.iter().map(|edge| {
361            (
362                (
363                    edge.location.path.clone(),
364                    edge.location.line,
365                    edge.location.column,
366                ),
367                edge.clone(),
368            )
369        }));
370        self
371    }
372
373    /// Finalizes all selected frontends after extraction and applies cross-file trust effects.
374    ///
375    /// Call this before analyzing accumulated definitions. A later source can invalidate an
376    /// earlier registration; individual extraction results are provisional until this operation.
377    pub fn finalize(
378        &mut self,
379        files: &[SourceFile],
380        definitions: &mut Vec<StepDefinition>,
381    ) -> Result<SourceFinalization> {
382        let mut result = SourceFinalization::default();
383        let mut completed = std::collections::BTreeSet::new();
384        for file in files {
385            let adapter = adapter_for_language(file.language);
386            if completed.insert(adapter.name()) {
387                let finalization = adapter.finalize_session(self)?;
388                result.uncertainties.extend(finalization.uncertainties);
389                result.advisories.extend(finalization.advisories);
390            }
391        }
392        result.apply(definitions);
393        Ok(result)
394    }
395
396    /// Supplies loading calls that discovery refused to follow, so their diagnostics name the cause.
397    pub fn with_dependency_gaps(mut self, gaps: &[DependencyGap]) -> Self {
398        self.dependency_gaps.extend(gaps.iter().map(|gap| {
399            (
400                (
401                    gap.location.path.clone(),
402                    gap.location.line,
403                    gap.location.column,
404                ),
405                gap.reason.clone(),
406            )
407        }));
408        self
409    }
410
411    /// The reason discovery refused to follow the loading call at `location`, if it did.
412    pub(crate) fn dependency_gap(&self, location: &SourceLocation) -> Option<&str> {
413        self.dependency_gaps
414            .get(&(location.path.clone(), location.line, location.column))
415            .map(String::as_str)
416    }
417
418    pub(crate) fn dependency_resolved(&self, location: &SourceLocation) -> bool {
419        self.dependencies
420            .contains_key(&(location.path.clone(), location.line, location.column))
421    }
422
423    pub(crate) fn dependency_edges(&self) -> Vec<SourceDependency> {
424        self.dependencies.values().cloned().collect()
425    }
426
427    /// Creates an extraction session whose imported modules must remain inside `root`.
428    pub fn new(root: &Path) -> Self {
429        Self::with_registrations(root, &[])
430    }
431
432    /// Creates a session that also treats `registrations` as step-registration function names.
433    ///
434    /// Use this for project-declared wrappers that static inference cannot recognize, such as a
435    /// helper that builds its registration call dynamically.
436    pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
437        Self::with_options(root, registrations, &[])
438    }
439
440    /// Creates a session that also trusts `assertion_modules` as assertion-factory providers.
441    ///
442    /// Use this for a local module that re-exports `expect` from a supported assertion package, or
443    /// for a runner this build does not recognize by name. Without it such a factory is untrusted
444    /// and its expected values are not treated as behaviour.
445    pub fn with_options(
446        root: &Path,
447        registrations: &[String],
448        assertion_modules: &[String],
449    ) -> Self {
450        let mut session = Self {
451            root: Some(root.to_owned()),
452            registrations: registrations.to_vec(),
453            assertion_modules: assertion_modules.to_vec(),
454            ..Self::default()
455        };
456        // Preserve construction-time root resolution, including a failed canonicalization.
457        for registration in SOURCE_ADAPTER_REGISTRY {
458            if let Some(initialize) = registration.initialize_session {
459                initialize(&mut session);
460            }
461        }
462        session
463    }
464
465    pub(crate) fn initialize<T: AdapterSessionState>(&mut self) {
466        self.states.entry(TypeId::of::<T>()).or_insert_with(|| {
467            Box::new(T::initialize(
468                self.root.as_deref(),
469                &self.registrations,
470                &self.assertion_modules,
471            ))
472        });
473    }
474
475    pub(crate) fn state<T: AdapterSessionState>(&mut self) -> Result<&mut T> {
476        // Explicit-root sessions must never establish a boundary later than construction.
477        if self.root.is_none() {
478            self.initialize::<T>();
479        }
480        self.states
481            .get_mut(&TypeId::of::<T>())
482            .and_then(|state| {
483                let state: &mut dyn Any = state.as_mut();
484                state.downcast_mut::<T>()
485            })
486            .with_context(|| {
487                format!(
488                    "source adapter state {} is uninitialized or incompatible",
489                    std::any::type_name::<T>()
490                )
491            })
492    }
493}
494
495/// Converts one supported definition-source language into the shared definition IR.
496pub trait SourceAdapter: Sync {
497    /// Stable adapter name used in diagnostics and registry inspection.
498    fn name(&self) -> &'static str;
499
500    /// Parser language produced by this adapter registration.
501    fn language(&self) -> SourceLanguage;
502
503    /// Extracts definitions from in-memory source using `file` for source locations.
504    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
505
506    /// Whether this frontend participates when no definition globs are configured.
507    fn discover_by_default(&self) -> bool {
508        true
509    }
510
511    /// Classifies a source using this frontend's bounded input-filter policy.
512    fn inspect_source(
513        &self,
514        _file: &SourceFile,
515        _registrations: &[String],
516    ) -> Option<SourceExclusion> {
517        None
518    }
519
520    /// Whether missing source metadata or reads leave indirect step usage unknown.
521    fn supports_indirect_usage(&self) -> bool {
522        false
523    }
524
525    /// Resolves source parameter declarations without granting unknown matcher authority.
526    fn resolve_parameter_types(
527        &self,
528        declarations: Vec<SourceParameterType>,
529        configured: &std::collections::BTreeMap<String, String>,
530    ) -> (std::collections::BTreeMap<String, String>, Vec<String>) {
531        if declarations.is_empty() {
532            (configured.clone(), Vec::new())
533        } else {
534            (
535                Default::default(),
536                vec!["frontend does not support source parameter declarations".into()],
537            )
538        }
539    }
540
541    /// Prepares selected-source evidence before extraction, without executing source code.
542    fn prepare_session(
543        &self,
544        _files: &[SourceFile],
545        _session: &mut SourceExtractionSession,
546    ) -> Result<()> {
547        Ok(())
548    }
549
550    /// Reports frontend-owned trust effects after every selected source has been extracted.
551    fn finalize_session(
552        &self,
553        _session: &mut SourceExtractionSession,
554    ) -> Result<SourceFinalization> {
555        Ok(SourceFinalization::default())
556    }
557
558    /// Extracts definitions while sharing bounded state with other files in the same run.
559    fn extract_with_session(
560        &self,
561        source: &str,
562        file: &SourceFile,
563        _session: &mut SourceExtractionSession,
564    ) -> Result<Extraction> {
565        self.extract(source, file)
566    }
567
568    /// Reads and extracts one source file.
569    fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
570        let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
571        self.extract(&source, file)
572    }
573
574    /// Reads and extracts one source file using shared run state.
575    fn extract_file_with_session(
576        &self,
577        file: &SourceFile,
578        session: &mut SourceExtractionSession,
579    ) -> Result<Extraction> {
580        let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
581        self.extract_with_session(&source, file, session)
582    }
583}
584
585type DependencyDiscovery =
586    fn(&crate::config::Config, &globset::GlobSet, &mut crate::discovery::DiscoveredFiles);
587
588/// One suffix-to-adapter registration.
589#[non_exhaustive]
590#[derive(Clone, Copy)]
591pub struct SourceAdapterRegistration {
592    /// Filename suffix, including the leading dot.
593    pub suffix: &'static str,
594    /// Adapter responsible for matching sources.
595    pub adapter: &'static dyn SourceAdapter,
596    initialize_session: Option<fn(&mut SourceExtractionSession)>,
597    discover_dependencies: Option<DependencyDiscovery>,
598}
599
600impl SourceAdapterRegistration {
601    /// Creates one suffix-to-adapter registration.
602    pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
603        Self {
604            suffix,
605            adapter,
606            initialize_session: None,
607            discover_dependencies: None,
608        }
609    }
610
611    const fn with_session<A: StatefulSourceAdapter>(
612        suffix: &'static str,
613        adapter: &'static A,
614    ) -> Self {
615        Self {
616            suffix,
617            adapter,
618            initialize_session: Some(SourceExtractionSession::initialize::<A::State>),
619            discover_dependencies: Some(A::discover_dependencies),
620        }
621    }
622}
623
624/// The authoritative language/adapter mapping: one table drives both the suffix registry and the
625/// language lookup, so the two routing surfaces cannot drift apart. Adding a language means one
626/// row here; the expansion stays an exhaustive match, so routing remains total without a
627/// fallible lookup.
628macro_rules! register_source_adapters {
629    ( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
630        /// Registered definition-source suffixes in deterministic lookup order.
631        pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
632            $(
633                $(
634                    SourceAdapterRegistration::with_session($suffix, &$adapter),
635                )+
636            )+
637        ];
638
639        /// Returns the adapter for an already classified source language.
640        pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
641            match language {
642                $(SourceLanguage::$language => &$adapter,)+
643            }
644        }
645    };
646}
647
648register_source_adapters! {
649    Ruby => crate::ruby::RUBY_ADAPTER : ".rb";
650    JavaScript => crate::typescript::JAVASCRIPT_ADAPTER : ".mjs", ".cjs", ".jsx", ".js";
651    TypeScript => crate::typescript::TYPESCRIPT_ADAPTER : ".mts", ".cts", ".ts";
652    Tsx => crate::typescript::TSX_ADAPTER : ".tsx";
653}
654
655/// Returns the adapter registered for `path`, rejecting declaration and source-map files.
656pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
657    let extension = path.extension()?.to_str()?;
658    if extension == "map" || is_declaration_file(path, extension) {
659        return None;
660    }
661    SOURCE_ADAPTER_REGISTRY
662        .iter()
663        .find(|registration| extension == registration.suffix.trim_start_matches('.'))
664        .map(|registration| registration.adapter)
665}
666
667fn is_declaration_file(path: &Path, extension: &str) -> bool {
668    matches!(extension, "ts" | "mts" | "cts")
669        && path.file_stem().is_some_and(|stem| {
670            stem == ".d"
671                || Path::new(stem)
672                    .extension()
673                    .is_some_and(|extension| extension == "d")
674        })
675}
676
677/// Classifies a path using the registered suffixes.
678pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
679    adapter_for_path(path).map(SourceAdapter::language)
680}
681
682/// Returns the registered suffixes for tooling and adapter conformance tests.
683pub fn registered_suffixes() -> Vec<&'static str> {
684    SOURCE_ADAPTER_REGISTRY
685        .iter()
686        .map(|registration| registration.suffix)
687        .collect()
688}
689
690#[cfg(test)]
691mod tests;
692
693/// Runs each selected frontend's internal dependency policy once.
694pub(crate) fn discover_dependencies(
695    config: &crate::config::Config,
696    excludes: &globset::GlobSet,
697    files: &mut crate::discovery::DiscoveredFiles,
698) {
699    let mut completed = std::collections::BTreeSet::new();
700    let callbacks: Vec<_> = SOURCE_ADAPTER_REGISTRY
701        .iter()
702        .filter(|entry| {
703            files
704                .definitions
705                .iter()
706                .any(|file| file.language == entry.adapter.language())
707        })
708        .filter(|entry| completed.insert(entry.adapter.name()))
709        .filter_map(|entry| entry.discover_dependencies)
710        .collect();
711    for callback in callbacks {
712        callback(config, excludes, files);
713    }
714}