1use crate::model::{SourceLocation, StepDefinition};
4use anyhow::{Context, Result};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SourceLanguage {
11 JavaScript,
13 TypeScript,
15 Tsx,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SourceFile {
22 pub path: PathBuf,
24 pub language: SourceLanguage,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ExtractionDiagnosticLevel {
31 Warning,
33 Error,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ExtractionDiagnostic {
40 pub level: ExtractionDiagnosticLevel,
42 pub location: SourceLocation,
44 pub message: String,
46}
47
48#[derive(Debug, Clone, Default, PartialEq)]
50pub struct Extraction {
51 pub definitions: Vec<StepDefinition>,
53 pub diagnostics: Vec<ExtractionDiagnostic>,
55}
56
57pub trait SourceAdapter: Sync {
59 fn name(&self) -> &'static str;
61
62 fn language(&self) -> SourceLanguage;
64
65 fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
67
68 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#[derive(Clone, Copy)]
78pub struct SourceAdapterRegistration {
79 pub suffix: &'static str,
81 pub adapter: &'static dyn SourceAdapter,
83}
84
85pub 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
121pub 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
137pub 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
146pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
148 adapter_for_path(path).map(SourceAdapter::language)
149}
150
151pub 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}