use crate::model::{SourceLocation, StepDefinition};
use crate::resource_limits::{read_utf8, MAX_PROJECT_INPUT_BYTES};
use anyhow::{Context, Result};
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::panic::{RefUnwindSafe, UnwindSafe};
use std::path::{Path, PathBuf};
pub(crate) const UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX: &str =
"unresolved step-registration calls:";
pub(crate) const UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX: &str =
"source contains JavaScript/TypeScript syntax errors";
pub(crate) const UNSUPPORTED_MATCHER_DIAGNOSTIC: &str =
"dynamic or unsupported step matcher cannot be analyzed statically";
pub(crate) const INVALID_MATCHER_DIAGNOSTIC: &str =
"invalid JavaScript escape sequence in step matcher";
#[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)
|| matches!(
diagnostic.message.as_str(),
UNSUPPORTED_MATCHER_DIAGNOSTIC | INVALID_MATCHER_DIAGNOSTIC
)
|| is_unparseable_diagnostic(diagnostic)
}
pub(crate) fn is_unparseable_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
diagnostic
.message
.starts_with(UNPARSEABLE_SOURCE_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 {
root: Option<PathBuf>,
registrations: Vec<String>,
assertion_modules: Vec<String>,
states: HashMap<TypeId, Box<dyn Any + Send + Sync + UnwindSafe + RefUnwindSafe>>,
}
pub(crate) trait AdapterSessionState:
Any + Send + Sync + UnwindSafe + RefUnwindSafe
{
fn initialize(
root: Option<&Path>,
registrations: &[String],
assertion_modules: &[String],
) -> Self;
}
pub(crate) trait StatefulSourceAdapter: SourceAdapter {
type State: AdapterSessionState;
}
impl SourceExtractionSession {
pub fn new(root: &Path) -> Self {
Self::with_registrations(root, &[])
}
pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
Self::with_options(root, registrations, &[])
}
pub fn with_options(
root: &Path,
registrations: &[String],
assertion_modules: &[String],
) -> Self {
let mut session = Self {
root: Some(root.to_owned()),
registrations: registrations.to_vec(),
assertion_modules: assertion_modules.to_vec(),
..Self::default()
};
for registration in SOURCE_ADAPTER_REGISTRY {
if let Some(initialize) = registration.initialize_session {
initialize(&mut session);
}
}
session
}
pub(crate) fn initialize<T: AdapterSessionState>(&mut self) {
self.states.entry(TypeId::of::<T>()).or_insert_with(|| {
Box::new(T::initialize(
self.root.as_deref(),
&self.registrations,
&self.assertion_modules,
))
});
}
pub(crate) fn state<T: AdapterSessionState>(&mut self) -> Result<&mut T> {
if self.root.is_none() {
self.initialize::<T>();
}
self.states
.get_mut(&TypeId::of::<T>())
.and_then(|state| {
let state: &mut dyn Any = state.as_mut();
state.downcast_mut::<T>()
})
.with_context(|| {
format!(
"source adapter state {} is uninitialized or incompatible",
std::any::type_name::<T>()
)
})
}
}
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,
initialize_session: Option<fn(&mut SourceExtractionSession)>,
}
impl SourceAdapterRegistration {
pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
Self {
suffix,
adapter,
initialize_session: None,
}
}
const fn with_session<A: StatefulSourceAdapter>(
suffix: &'static str,
adapter: &'static A,
) -> Self {
Self {
suffix,
adapter,
initialize_session: Some(SourceExtractionSession::initialize::<A::State>),
}
}
}
macro_rules! register_source_adapters {
( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
$(
$(
SourceAdapterRegistration::with_session($suffix, &$adapter),
)+
)+
];
pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
match language {
$(SourceLanguage::$language => &$adapter,)+
}
}
};
}
register_source_adapters! {
JavaScript => crate::typescript::JAVASCRIPT_ADAPTER : ".mjs", ".cjs", ".jsx", ".js";
TypeScript => crate::typescript::TYPESCRIPT_ADAPTER : ".mts", ".cts", ".ts";
Tsx => crate::typescript::TSX_ADAPTER : ".tsx";
}
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 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;