use crate::model::{SourceLocation, StepDefinition};
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceLanguage {
JavaScript,
TypeScript,
Tsx,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceFile {
pub path: PathBuf,
pub language: SourceLanguage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtractionDiagnosticLevel {
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractionDiagnostic {
pub level: ExtractionDiagnosticLevel,
pub location: SourceLocation,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Extraction {
pub definitions: Vec<StepDefinition>,
pub diagnostics: Vec<ExtractionDiagnostic>,
}
pub trait SourceAdapter: Sync {
fn name(&self) -> &'static str;
fn language(&self) -> SourceLanguage;
fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
let source = fs::read_to_string(&file.path)
.with_context(|| format!("failed to read source file {}", file.path.display()))?;
self.extract(&source, file)
}
}
#[derive(Clone, Copy)]
pub struct SourceAdapterRegistration {
pub suffix: &'static str,
pub adapter: &'static dyn SourceAdapter,
}
pub static SOURCE_ADAPTER_REGISTRY: [SourceAdapterRegistration; 8] = [
SourceAdapterRegistration {
suffix: ".mjs",
adapter: &crate::typescript::JAVASCRIPT_ADAPTER,
},
SourceAdapterRegistration {
suffix: ".cjs",
adapter: &crate::typescript::JAVASCRIPT_ADAPTER,
},
SourceAdapterRegistration {
suffix: ".jsx",
adapter: &crate::typescript::JAVASCRIPT_ADAPTER,
},
SourceAdapterRegistration {
suffix: ".js",
adapter: &crate::typescript::JAVASCRIPT_ADAPTER,
},
SourceAdapterRegistration {
suffix: ".mts",
adapter: &crate::typescript::TYPESCRIPT_ADAPTER,
},
SourceAdapterRegistration {
suffix: ".cts",
adapter: &crate::typescript::TYPESCRIPT_ADAPTER,
},
SourceAdapterRegistration {
suffix: ".tsx",
adapter: &crate::typescript::TSX_ADAPTER,
},
SourceAdapterRegistration {
suffix: ".ts",
adapter: &crate::typescript::TYPESCRIPT_ADAPTER,
},
];
pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
let name = path.file_name()?.to_str()?;
if name.ends_with(".d.ts")
|| name.ends_with(".d.mts")
|| name.ends_with(".d.cts")
|| name.ends_with(".map")
{
return None;
}
SOURCE_ADAPTER_REGISTRY
.iter()
.find(|registration| name.ends_with(registration.suffix))
.map(|registration| registration.adapter)
}
pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
match language {
SourceLanguage::JavaScript => &crate::typescript::JAVASCRIPT_ADAPTER,
SourceLanguage::TypeScript => &crate::typescript::TYPESCRIPT_ADAPTER,
SourceLanguage::Tsx => &crate::typescript::TSX_ADAPTER,
}
}
pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
adapter_for_path(path).map(SourceAdapter::language)
}
pub fn registered_suffixes() -> Vec<&'static str> {
SOURCE_ADAPTER_REGISTRY
.iter()
.map(|registration| registration.suffix)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
#[test]
fn registry_routes_supported_suffixes() {
let cases = [
("steps.js", SourceLanguage::JavaScript),
("steps.mjs", SourceLanguage::JavaScript),
("steps.cjs", SourceLanguage::JavaScript),
("steps.jsx", SourceLanguage::JavaScript),
("steps.ts", SourceLanguage::TypeScript),
("steps.mts", SourceLanguage::TypeScript),
("steps.cts", SourceLanguage::TypeScript),
("steps.tsx", SourceLanguage::Tsx),
];
for (path, expected) in cases {
let adapter = adapter_for_path(Path::new(path)).expect(path);
assert_eq!(adapter.language(), expected, "{path}");
assert!(!adapter.name().is_empty());
}
assert_eq!(registered_suffixes().len(), cases.len());
}
#[test]
fn every_registration_has_a_unique_suffix_and_produces_shared_ir() {
let mut suffixes = BTreeSet::new();
for registration in SOURCE_ADAPTER_REGISTRY {
assert!(suffixes.insert(registration.suffix), "duplicate suffix");
let file = SourceFile {
path: PathBuf::from(format!("steps{}", registration.suffix)),
language: registration.adapter.language(),
};
let extraction = registration
.adapter
.extract("Given('a registered step', () => run())", &file)
.unwrap();
assert_eq!(extraction.definitions.len(), 1, "{}", registration.suffix);
assert!(extraction.diagnostics.is_empty(), "{}", registration.suffix);
}
}
#[test]
fn registry_rejects_declarations_maps_and_unknown_languages() {
for path in [
"types.d.ts",
"types.d.mts",
"types.d.cts",
"steps.js.map",
"steps.py",
] {
assert!(adapter_for_path(Path::new(path)).is_none(), "{path}");
}
}
}