1use crate::model::{SourceLocation, StepDefinition};
4use crate::resource_limits::{read_utf8, MAX_PROJECT_INPUT_BYTES};
5use anyhow::Result;
6use std::path::{Path, PathBuf};
7
8pub(crate) const UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX: &str =
9 "unresolved step-registration calls:";
10
11#[non_exhaustive]
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SourceLanguage {
15 JavaScript,
17 TypeScript,
19 Tsx,
21}
22
23pub(crate) fn grammar_for_language(language: SourceLanguage) -> tree_sitter::Language {
25 match language {
26 SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
27 SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
28 SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SourceFile {
35 pub path: PathBuf,
37 pub language: SourceLanguage,
39}
40
41#[non_exhaustive]
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ExtractionDiagnosticLevel {
45 Warning,
47 Error,
49}
50
51#[non_exhaustive]
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ExtractionDiagnostic {
55 pub level: ExtractionDiagnosticLevel,
57 pub location: SourceLocation,
59 pub message: String,
61}
62
63impl ExtractionDiagnostic {
64 pub fn new(
66 level: ExtractionDiagnosticLevel,
67 location: SourceLocation,
68 message: impl Into<String>,
69 ) -> Self {
70 Self {
71 level,
72 location,
73 message: message.into(),
74 }
75 }
76}
77
78pub(crate) fn is_completeness_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
79 diagnostic
80 .message
81 .starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
82}
83
84#[non_exhaustive]
86#[derive(Debug, Clone, Default, PartialEq)]
87pub struct Extraction {
88 pub definitions: Vec<StepDefinition>,
90 pub diagnostics: Vec<ExtractionDiagnostic>,
92}
93
94impl Extraction {
95 pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
97 Self {
98 definitions,
99 diagnostics,
100 }
101 }
102}
103
104#[derive(Default)]
109pub struct SourceExtractionSession {
110 pub(crate) typescript: crate::typescript::TypeScriptExtractionSession,
111}
112
113impl SourceExtractionSession {
114 pub fn new(root: &Path) -> Self {
116 Self::with_registrations(root, &[])
117 }
118
119 pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
124 Self {
125 typescript: crate::typescript::TypeScriptExtractionSession::for_root(
126 root,
127 registrations,
128 ),
129 }
130 }
131}
132
133pub trait SourceAdapter: Sync {
135 fn name(&self) -> &'static str;
137
138 fn language(&self) -> SourceLanguage;
140
141 fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
143
144 fn extract_with_session(
146 &self,
147 source: &str,
148 file: &SourceFile,
149 _session: &mut SourceExtractionSession,
150 ) -> Result<Extraction> {
151 self.extract(source, file)
152 }
153
154 fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
156 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
157 self.extract(&source, file)
158 }
159
160 fn extract_file_with_session(
162 &self,
163 file: &SourceFile,
164 session: &mut SourceExtractionSession,
165 ) -> Result<Extraction> {
166 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
167 self.extract_with_session(&source, file, session)
168 }
169}
170
171#[non_exhaustive]
173#[derive(Clone, Copy)]
174pub struct SourceAdapterRegistration {
175 pub suffix: &'static str,
177 pub adapter: &'static dyn SourceAdapter,
179}
180
181impl SourceAdapterRegistration {
182 pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
184 Self { suffix, adapter }
185 }
186}
187
188pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
190 SourceAdapterRegistration::new(".mjs", &crate::typescript::JAVASCRIPT_ADAPTER),
191 SourceAdapterRegistration::new(".cjs", &crate::typescript::JAVASCRIPT_ADAPTER),
192 SourceAdapterRegistration::new(".jsx", &crate::typescript::JAVASCRIPT_ADAPTER),
193 SourceAdapterRegistration::new(".js", &crate::typescript::JAVASCRIPT_ADAPTER),
194 SourceAdapterRegistration::new(".mts", &crate::typescript::TYPESCRIPT_ADAPTER),
195 SourceAdapterRegistration::new(".cts", &crate::typescript::TYPESCRIPT_ADAPTER),
196 SourceAdapterRegistration::new(".tsx", &crate::typescript::TSX_ADAPTER),
197 SourceAdapterRegistration::new(".ts", &crate::typescript::TYPESCRIPT_ADAPTER),
198];
199
200pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
202 let extension = path.extension()?.to_str()?;
203 if extension == "map" || is_declaration_file(path, extension) {
204 return None;
205 }
206 SOURCE_ADAPTER_REGISTRY
207 .iter()
208 .find(|registration| extension == registration.suffix.trim_start_matches('.'))
209 .map(|registration| registration.adapter)
210}
211
212fn is_declaration_file(path: &Path, extension: &str) -> bool {
213 matches!(extension, "ts" | "mts" | "cts")
214 && path.file_stem().is_some_and(|stem| {
215 stem == ".d"
216 || Path::new(stem)
217 .extension()
218 .is_some_and(|extension| extension == "d")
219 })
220}
221
222pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
224 match language {
225 SourceLanguage::JavaScript => &crate::typescript::JAVASCRIPT_ADAPTER,
226 SourceLanguage::TypeScript => &crate::typescript::TYPESCRIPT_ADAPTER,
227 SourceLanguage::Tsx => &crate::typescript::TSX_ADAPTER,
228 }
229}
230
231pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
233 adapter_for_path(path).map(SourceAdapter::language)
234}
235
236pub fn registered_suffixes() -> Vec<&'static str> {
238 SOURCE_ADAPTER_REGISTRY
239 .iter()
240 .map(|registration| registration.suffix)
241 .collect()
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use std::collections::BTreeSet;
248
249 struct DefaultSessionAdapter;
250
251 impl SourceAdapter for DefaultSessionAdapter {
252 fn name(&self) -> &'static str {
253 "default-session-test"
254 }
255
256 fn language(&self) -> SourceLanguage {
257 SourceLanguage::JavaScript
258 }
259
260 fn extract(&self, _source: &str, _file: &SourceFile) -> Result<Extraction> {
261 anyhow::bail!("default extract delegation reached")
262 }
263 }
264
265 #[test]
266 fn registry_routes_supported_suffixes() {
267 let cases = [
268 ("steps.js", SourceLanguage::JavaScript),
269 ("steps.mjs", SourceLanguage::JavaScript),
270 ("steps.cjs", SourceLanguage::JavaScript),
271 ("steps.jsx", SourceLanguage::JavaScript),
272 ("steps.ts", SourceLanguage::TypeScript),
273 ("steps.mts", SourceLanguage::TypeScript),
274 ("steps.cts", SourceLanguage::TypeScript),
275 ("steps.tsx", SourceLanguage::Tsx),
276 ];
277 for (path, expected) in cases {
278 let adapter = adapter_for_path(Path::new(path)).expect(path);
279 assert_eq!(adapter.language(), expected, "{path}");
280 assert!(!adapter.name().is_empty());
281 }
282 assert_eq!(registered_suffixes().len(), cases.len());
283 }
284
285 #[test]
286 fn source_languages_select_their_registered_tree_sitter_grammar() {
287 let cases = [
288 (SourceLanguage::JavaScript, "const value = 1;"),
289 (SourceLanguage::TypeScript, "const value: number = 1;"),
290 (SourceLanguage::Tsx, "const value = <div />;"),
291 ];
292
293 for (language, source) in cases {
294 let mut parser = tree_sitter::Parser::new();
295 parser
296 .set_language(&grammar_for_language(language))
297 .unwrap();
298 let tree = parser.parse(source, None).unwrap();
299 assert!(!tree.root_node().has_error(), "{language:?}");
300 }
301 }
302
303 #[test]
304 fn every_registration_has_a_unique_suffix_and_produces_shared_ir() {
305 let mut suffixes = BTreeSet::new();
306 for registration in SOURCE_ADAPTER_REGISTRY {
307 assert!(suffixes.insert(registration.suffix), "duplicate suffix");
308 let file = SourceFile {
309 path: PathBuf::from(format!("steps{}", registration.suffix)),
310 language: registration.adapter.language(),
311 };
312 let extraction = registration
313 .adapter
314 .extract("Given('a registered step', () => run())", &file)
315 .unwrap();
316 assert_eq!(extraction.definitions.len(), 1, "{}", registration.suffix);
317 assert!(extraction.diagnostics.is_empty(), "{}", registration.suffix);
318 }
319 }
320
321 #[test]
322 fn registry_rejects_declarations_maps_and_unknown_languages() {
323 for path in [
324 "types.d.ts",
325 "types.d.mts",
326 "types.d.cts",
327 ".d.ts",
328 ".d.mts",
329 ".d.cts",
330 "steps.js.map",
331 "steps.py",
332 ] {
333 assert!(adapter_for_path(Path::new(path)).is_none(), "{path}");
334 }
335 }
336
337 #[cfg(unix)]
338 #[test]
339 fn registry_classifies_non_utf8_names_by_their_ascii_extension() {
340 use std::ffi::OsString;
341 use std::os::unix::ffi::OsStringExt;
342
343 let source = PathBuf::from(OsString::from_vec(b"steps-\xff.ts".to_vec()));
344 assert_eq!(language_for_path(&source), Some(SourceLanguage::TypeScript));
345
346 let declaration = PathBuf::from(OsString::from_vec(b"types-\xff.d.ts".to_vec()));
347 assert!(adapter_for_path(&declaration).is_none());
348 }
349
350 #[test]
351 fn adapter_session_default_preserves_existing_extract_implementations() {
352 let directory = tempfile::tempdir().unwrap();
353 let file = SourceFile {
354 path: directory.path().join("steps.js"),
355 language: SourceLanguage::JavaScript,
356 };
357 let mut session = SourceExtractionSession::new(directory.path());
358
359 let adapter = DefaultSessionAdapter;
360 assert_eq!(adapter.name(), "default-session-test");
361 assert_eq!(adapter.language(), SourceLanguage::JavaScript);
362
363 let error = adapter
364 .extract_with_session("", &file, &mut session)
365 .unwrap_err();
366
367 assert_eq!(error.to_string(), "default extract delegation reached");
368 }
369}