Skip to main content

cuke_dedup/
source_adapter.rs

1//! Definition-source adapter contracts and extension-based routing.
2
3use crate::model::{SourceLocation, StepDefinition};
4use anyhow::{Context, Result};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Parser language selected for a definition source.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SourceLanguage {
11    /// JavaScript, JSX, or their module variants.
12    JavaScript,
13    /// TypeScript or its module variants.
14    TypeScript,
15    /// TypeScript with JSX syntax.
16    Tsx,
17}
18
19/// A discovered definition source and the language used to parse it.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SourceFile {
22    /// Absolute source path.
23    pub path: PathBuf,
24    /// Parser language inferred from the registered extension.
25    pub language: SourceLanguage,
26}
27
28/// Impact of a source-extraction diagnostic.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ExtractionDiagnosticLevel {
31    /// Analysis continues, but a static-analysis limitation applies.
32    Warning,
33    /// The source contains a malformed step registration.
34    Error,
35}
36
37/// A source-localized problem encountered while extracting definitions.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ExtractionDiagnostic {
40    /// Diagnostic impact.
41    pub level: ExtractionDiagnosticLevel,
42    /// Source position of the affected matcher.
43    pub location: SourceLocation,
44    /// Human-readable explanation.
45    pub message: String,
46}
47
48/// Definitions and non-fatal diagnostics extracted from one source.
49#[derive(Debug, Clone, Default, PartialEq)]
50pub struct Extraction {
51    /// Successfully extracted definitions.
52    pub definitions: Vec<StepDefinition>,
53    /// Problems that did not prevent extraction of the rest of the file.
54    pub diagnostics: Vec<ExtractionDiagnostic>,
55}
56
57/// Converts one supported definition-source language into the shared definition IR.
58pub trait SourceAdapter: Sync {
59    /// Stable adapter name used in diagnostics and registry inspection.
60    fn name(&self) -> &'static str;
61
62    /// Parser language produced by this adapter registration.
63    fn language(&self) -> SourceLanguage;
64
65    /// Extracts definitions from in-memory source using `file` for source locations.
66    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
67
68    /// Reads and extracts one source file.
69    fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
70        let source = fs::read_to_string(&file.path)
71            .with_context(|| format!("failed to read source file {}", file.path.display()))?;
72        self.extract(&source, file)
73    }
74}
75
76/// One suffix-to-adapter registration.
77#[derive(Clone, Copy)]
78pub struct SourceAdapterRegistration {
79    /// Filename suffix, including the leading dot.
80    pub suffix: &'static str,
81    /// Adapter responsible for matching sources.
82    pub adapter: &'static dyn SourceAdapter,
83}
84
85/// Registered definition-source suffixes in deterministic lookup order.
86pub static SOURCE_ADAPTER_REGISTRY: [SourceAdapterRegistration; 8] = [
87    SourceAdapterRegistration {
88        suffix: ".mjs",
89        adapter: &crate::typescript::JAVASCRIPT_ADAPTER,
90    },
91    SourceAdapterRegistration {
92        suffix: ".cjs",
93        adapter: &crate::typescript::JAVASCRIPT_ADAPTER,
94    },
95    SourceAdapterRegistration {
96        suffix: ".jsx",
97        adapter: &crate::typescript::JAVASCRIPT_ADAPTER,
98    },
99    SourceAdapterRegistration {
100        suffix: ".js",
101        adapter: &crate::typescript::JAVASCRIPT_ADAPTER,
102    },
103    SourceAdapterRegistration {
104        suffix: ".mts",
105        adapter: &crate::typescript::TYPESCRIPT_ADAPTER,
106    },
107    SourceAdapterRegistration {
108        suffix: ".cts",
109        adapter: &crate::typescript::TYPESCRIPT_ADAPTER,
110    },
111    SourceAdapterRegistration {
112        suffix: ".tsx",
113        adapter: &crate::typescript::TSX_ADAPTER,
114    },
115    SourceAdapterRegistration {
116        suffix: ".ts",
117        adapter: &crate::typescript::TYPESCRIPT_ADAPTER,
118    },
119];
120
121/// Returns the adapter registered for `path`, rejecting declaration and source-map files.
122pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
123    let name = path.file_name()?.to_str()?;
124    if name.ends_with(".d.ts")
125        || name.ends_with(".d.mts")
126        || name.ends_with(".d.cts")
127        || name.ends_with(".map")
128    {
129        return None;
130    }
131    SOURCE_ADAPTER_REGISTRY
132        .iter()
133        .find(|registration| name.ends_with(registration.suffix))
134        .map(|registration| registration.adapter)
135}
136
137/// Returns the adapter for an already classified source language.
138pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
139    match language {
140        SourceLanguage::JavaScript => &crate::typescript::JAVASCRIPT_ADAPTER,
141        SourceLanguage::TypeScript => &crate::typescript::TYPESCRIPT_ADAPTER,
142        SourceLanguage::Tsx => &crate::typescript::TSX_ADAPTER,
143    }
144}
145
146/// Classifies a path using the registered suffixes.
147pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
148    adapter_for_path(path).map(SourceAdapter::language)
149}
150
151/// Returns the registered suffixes for tooling and adapter conformance tests.
152pub fn registered_suffixes() -> Vec<&'static str> {
153    SOURCE_ADAPTER_REGISTRY
154        .iter()
155        .map(|registration| registration.suffix)
156        .collect()
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use std::collections::BTreeSet;
163
164    #[test]
165    fn registry_routes_supported_suffixes() {
166        let cases = [
167            ("steps.js", SourceLanguage::JavaScript),
168            ("steps.mjs", SourceLanguage::JavaScript),
169            ("steps.cjs", SourceLanguage::JavaScript),
170            ("steps.jsx", SourceLanguage::JavaScript),
171            ("steps.ts", SourceLanguage::TypeScript),
172            ("steps.mts", SourceLanguage::TypeScript),
173            ("steps.cts", SourceLanguage::TypeScript),
174            ("steps.tsx", SourceLanguage::Tsx),
175        ];
176        for (path, expected) in cases {
177            let adapter = adapter_for_path(Path::new(path)).expect(path);
178            assert_eq!(adapter.language(), expected, "{path}");
179            assert!(!adapter.name().is_empty());
180        }
181        assert_eq!(registered_suffixes().len(), cases.len());
182    }
183
184    #[test]
185    fn every_registration_has_a_unique_suffix_and_produces_shared_ir() {
186        let mut suffixes = BTreeSet::new();
187        for registration in SOURCE_ADAPTER_REGISTRY {
188            assert!(suffixes.insert(registration.suffix), "duplicate suffix");
189            let file = SourceFile {
190                path: PathBuf::from(format!("steps{}", registration.suffix)),
191                language: registration.adapter.language(),
192            };
193            let extraction = registration
194                .adapter
195                .extract("Given('a registered step', () => run())", &file)
196                .unwrap();
197            assert_eq!(extraction.definitions.len(), 1, "{}", registration.suffix);
198            assert!(extraction.diagnostics.is_empty(), "{}", registration.suffix);
199        }
200    }
201
202    #[test]
203    fn registry_rejects_declarations_maps_and_unknown_languages() {
204        for path in [
205            "types.d.ts",
206            "types.d.mts",
207            "types.d.cts",
208            "steps.js.map",
209            "steps.py",
210        ] {
211            assert!(adapter_for_path(Path::new(path)).is_none(), "{path}");
212        }
213    }
214}