use crate::model::{SourceLocation, StepDefinition};
use crate::resource_limits::{read_utf8, MAX_PROJECT_INPUT_BYTES};
use anyhow::Result;
use std::path::{Path, PathBuf};
pub(crate) const UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX: &str =
"unresolved step-registration calls:";
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceLanguage {
JavaScript,
TypeScript,
Tsx,
}
pub(crate) fn grammar_for_language(language: SourceLanguage) -> tree_sitter::Language {
match language {
SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceFile {
pub path: PathBuf,
pub language: SourceLanguage,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtractionDiagnosticLevel {
Warning,
Error,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractionDiagnostic {
pub level: ExtractionDiagnosticLevel,
pub location: SourceLocation,
pub message: String,
}
impl ExtractionDiagnostic {
pub fn new(
level: ExtractionDiagnosticLevel,
location: SourceLocation,
message: impl Into<String>,
) -> Self {
Self {
level,
location,
message: message.into(),
}
}
}
pub(crate) fn is_completeness_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
diagnostic
.message
.starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Extraction {
pub definitions: Vec<StepDefinition>,
pub diagnostics: Vec<ExtractionDiagnostic>,
}
impl Extraction {
pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
Self {
definitions,
diagnostics,
}
}
}
#[derive(Default)]
pub struct SourceExtractionSession {
pub(crate) typescript: crate::typescript::TypeScriptExtractionSession,
}
impl SourceExtractionSession {
pub fn new(root: &Path) -> Self {
Self::with_registrations(root, &[])
}
pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
Self {
typescript: crate::typescript::TypeScriptExtractionSession::for_root(
root,
registrations,
),
}
}
}
pub trait SourceAdapter: Sync {
fn name(&self) -> &'static str;
fn language(&self) -> SourceLanguage;
fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
fn extract_with_session(
&self,
source: &str,
file: &SourceFile,
_session: &mut SourceExtractionSession,
) -> Result<Extraction> {
self.extract(source, file)
}
fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
self.extract(&source, file)
}
fn extract_file_with_session(
&self,
file: &SourceFile,
session: &mut SourceExtractionSession,
) -> Result<Extraction> {
let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
self.extract_with_session(&source, file, session)
}
}
#[non_exhaustive]
#[derive(Clone, Copy)]
pub struct SourceAdapterRegistration {
pub suffix: &'static str,
pub adapter: &'static dyn SourceAdapter,
}
impl SourceAdapterRegistration {
pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
Self { suffix, adapter }
}
}
pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
SourceAdapterRegistration::new(".mjs", &crate::typescript::JAVASCRIPT_ADAPTER),
SourceAdapterRegistration::new(".cjs", &crate::typescript::JAVASCRIPT_ADAPTER),
SourceAdapterRegistration::new(".jsx", &crate::typescript::JAVASCRIPT_ADAPTER),
SourceAdapterRegistration::new(".js", &crate::typescript::JAVASCRIPT_ADAPTER),
SourceAdapterRegistration::new(".mts", &crate::typescript::TYPESCRIPT_ADAPTER),
SourceAdapterRegistration::new(".cts", &crate::typescript::TYPESCRIPT_ADAPTER),
SourceAdapterRegistration::new(".tsx", &crate::typescript::TSX_ADAPTER),
SourceAdapterRegistration::new(".ts", &crate::typescript::TYPESCRIPT_ADAPTER),
];
pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
let extension = path.extension()?.to_str()?;
if extension == "map" || is_declaration_file(path, extension) {
return None;
}
SOURCE_ADAPTER_REGISTRY
.iter()
.find(|registration| extension == registration.suffix.trim_start_matches('.'))
.map(|registration| registration.adapter)
}
fn is_declaration_file(path: &Path, extension: &str) -> bool {
matches!(extension, "ts" | "mts" | "cts")
&& path.file_stem().is_some_and(|stem| {
stem == ".d"
|| Path::new(stem)
.extension()
.is_some_and(|extension| extension == "d")
})
}
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;
struct DefaultSessionAdapter;
impl SourceAdapter for DefaultSessionAdapter {
fn name(&self) -> &'static str {
"default-session-test"
}
fn language(&self) -> SourceLanguage {
SourceLanguage::JavaScript
}
fn extract(&self, _source: &str, _file: &SourceFile) -> Result<Extraction> {
anyhow::bail!("default extract delegation reached")
}
}
#[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 source_languages_select_their_registered_tree_sitter_grammar() {
let cases = [
(SourceLanguage::JavaScript, "const value = 1;"),
(SourceLanguage::TypeScript, "const value: number = 1;"),
(SourceLanguage::Tsx, "const value = <div />;"),
];
for (language, source) in cases {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&grammar_for_language(language))
.unwrap();
let tree = parser.parse(source, None).unwrap();
assert!(!tree.root_node().has_error(), "{language:?}");
}
}
#[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",
".d.ts",
".d.mts",
".d.cts",
"steps.js.map",
"steps.py",
] {
assert!(adapter_for_path(Path::new(path)).is_none(), "{path}");
}
}
#[cfg(unix)]
#[test]
fn registry_classifies_non_utf8_names_by_their_ascii_extension() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let source = PathBuf::from(OsString::from_vec(b"steps-\xff.ts".to_vec()));
assert_eq!(language_for_path(&source), Some(SourceLanguage::TypeScript));
let declaration = PathBuf::from(OsString::from_vec(b"types-\xff.d.ts".to_vec()));
assert!(adapter_for_path(&declaration).is_none());
}
#[test]
fn adapter_session_default_preserves_existing_extract_implementations() {
let directory = tempfile::tempdir().unwrap();
let file = SourceFile {
path: directory.path().join("steps.js"),
language: SourceLanguage::JavaScript,
};
let mut session = SourceExtractionSession::new(directory.path());
let adapter = DefaultSessionAdapter;
assert_eq!(adapter.name(), "default-session-test");
assert_eq!(adapter.language(), SourceLanguage::JavaScript);
let error = adapter
.extract_with_session("", &file, &mut session)
.unwrap_err();
assert_eq!(error.to_string(), "default extract delegation reached");
}
}