1use crate::model::{SourceLocation, StepDefinition};
4use crate::resource_limits::{read_utf8, MAX_PROJECT_INPUT_BYTES};
5use anyhow::{Context, Result};
6use std::any::{Any, TypeId};
7use std::collections::HashMap;
8use std::panic::{RefUnwindSafe, UnwindSafe};
9use std::path::{Path, PathBuf};
10
11pub(crate) const UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX: &str =
12 "unresolved step-registration calls:";
13
14pub(crate) const UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX: &str =
18 "source contains JavaScript/TypeScript syntax errors";
19
20#[non_exhaustive]
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SourceLanguage {
24 JavaScript,
26 TypeScript,
28 Tsx,
30}
31
32pub(crate) fn grammar_for_language(language: SourceLanguage) -> tree_sitter::Language {
34 match language {
35 SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
36 SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
37 SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SourceFile {
44 pub path: PathBuf,
46 pub language: SourceLanguage,
48}
49
50#[non_exhaustive]
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ExtractionDiagnosticLevel {
54 Warning,
56 Error,
58}
59
60#[non_exhaustive]
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ExtractionDiagnostic {
64 pub level: ExtractionDiagnosticLevel,
66 pub location: SourceLocation,
68 pub message: String,
70}
71
72impl ExtractionDiagnostic {
73 pub fn new(
75 level: ExtractionDiagnosticLevel,
76 location: SourceLocation,
77 message: impl Into<String>,
78 ) -> Self {
79 Self {
80 level,
81 location,
82 message: message.into(),
83 }
84 }
85}
86
87pub(crate) fn is_completeness_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
88 diagnostic
89 .message
90 .starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
91 || is_unparseable_diagnostic(diagnostic)
92}
93
94pub(crate) fn is_unparseable_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
98 diagnostic
99 .message
100 .starts_with(UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX)
101}
102
103#[non_exhaustive]
105#[derive(Debug, Clone, Default, PartialEq)]
106pub struct Extraction {
107 pub definitions: Vec<StepDefinition>,
109 pub diagnostics: Vec<ExtractionDiagnostic>,
111}
112
113impl Extraction {
114 pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
116 Self {
117 definitions,
118 diagnostics,
119 }
120 }
121}
122
123#[derive(Default)]
128pub struct SourceExtractionSession {
129 root: Option<PathBuf>,
130 registrations: Vec<String>,
131 assertion_modules: Vec<String>,
132 states: HashMap<TypeId, Box<dyn Any + Send + Sync + UnwindSafe + RefUnwindSafe>>,
133}
134
135pub(crate) trait AdapterSessionState:
138 Any + Send + Sync + UnwindSafe + RefUnwindSafe
139{
140 fn initialize(
141 root: Option<&Path>,
142 registrations: &[String],
143 assertion_modules: &[String],
144 ) -> Self;
145}
146
147pub(crate) trait StatefulSourceAdapter: SourceAdapter {
149 type State: AdapterSessionState;
150}
151
152impl SourceExtractionSession {
153 pub fn new(root: &Path) -> Self {
155 Self::with_registrations(root, &[])
156 }
157
158 pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
163 Self::with_options(root, registrations, &[])
164 }
165
166 pub fn with_options(
172 root: &Path,
173 registrations: &[String],
174 assertion_modules: &[String],
175 ) -> Self {
176 let mut session = Self {
177 root: Some(root.to_owned()),
178 registrations: registrations.to_vec(),
179 assertion_modules: assertion_modules.to_vec(),
180 ..Self::default()
181 };
182 for registration in SOURCE_ADAPTER_REGISTRY {
184 if let Some(initialize) = registration.initialize_session {
185 initialize(&mut session);
186 }
187 }
188 session
189 }
190
191 pub(crate) fn initialize<T: AdapterSessionState>(&mut self) {
192 self.states.entry(TypeId::of::<T>()).or_insert_with(|| {
193 Box::new(T::initialize(
194 self.root.as_deref(),
195 &self.registrations,
196 &self.assertion_modules,
197 ))
198 });
199 }
200
201 pub(crate) fn state<T: AdapterSessionState>(&mut self) -> Result<&mut T> {
202 if self.root.is_none() {
204 self.initialize::<T>();
205 }
206 self.states
207 .get_mut(&TypeId::of::<T>())
208 .and_then(|state| {
209 let state: &mut dyn Any = state.as_mut();
210 state.downcast_mut::<T>()
211 })
212 .with_context(|| {
213 format!(
214 "source adapter state {} is uninitialized or incompatible",
215 std::any::type_name::<T>()
216 )
217 })
218 }
219}
220
221pub trait SourceAdapter: Sync {
223 fn name(&self) -> &'static str;
225
226 fn language(&self) -> SourceLanguage;
228
229 fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
231
232 fn extract_with_session(
234 &self,
235 source: &str,
236 file: &SourceFile,
237 _session: &mut SourceExtractionSession,
238 ) -> Result<Extraction> {
239 self.extract(source, file)
240 }
241
242 fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
244 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
245 self.extract(&source, file)
246 }
247
248 fn extract_file_with_session(
250 &self,
251 file: &SourceFile,
252 session: &mut SourceExtractionSession,
253 ) -> Result<Extraction> {
254 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
255 self.extract_with_session(&source, file, session)
256 }
257}
258
259#[non_exhaustive]
261#[derive(Clone, Copy)]
262pub struct SourceAdapterRegistration {
263 pub suffix: &'static str,
265 pub adapter: &'static dyn SourceAdapter,
267 initialize_session: Option<fn(&mut SourceExtractionSession)>,
268}
269
270impl SourceAdapterRegistration {
271 pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
273 Self {
274 suffix,
275 adapter,
276 initialize_session: None,
277 }
278 }
279
280 const fn with_session<A: StatefulSourceAdapter>(
281 suffix: &'static str,
282 adapter: &'static A,
283 ) -> Self {
284 Self {
285 suffix,
286 adapter,
287 initialize_session: Some(SourceExtractionSession::initialize::<A::State>),
288 }
289 }
290}
291
292macro_rules! register_source_adapters {
297 ( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
298 pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
300 $(
301 $(
302 SourceAdapterRegistration::with_session($suffix, &$adapter),
303 )+
304 )+
305 ];
306
307 pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
309 match language {
310 $(SourceLanguage::$language => &$adapter,)+
311 }
312 }
313 };
314}
315
316register_source_adapters! {
317 JavaScript => crate::typescript::JAVASCRIPT_ADAPTER : ".mjs", ".cjs", ".jsx", ".js";
318 TypeScript => crate::typescript::TYPESCRIPT_ADAPTER : ".mts", ".cts", ".ts";
319 Tsx => crate::typescript::TSX_ADAPTER : ".tsx";
320}
321
322pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
324 let extension = path.extension()?.to_str()?;
325 if extension == "map" || is_declaration_file(path, extension) {
326 return None;
327 }
328 SOURCE_ADAPTER_REGISTRY
329 .iter()
330 .find(|registration| extension == registration.suffix.trim_start_matches('.'))
331 .map(|registration| registration.adapter)
332}
333
334fn is_declaration_file(path: &Path, extension: &str) -> bool {
335 matches!(extension, "ts" | "mts" | "cts")
336 && path.file_stem().is_some_and(|stem| {
337 stem == ".d"
338 || Path::new(stem)
339 .extension()
340 .is_some_and(|extension| extension == "d")
341 })
342}
343
344pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
346 adapter_for_path(path).map(SourceAdapter::language)
347}
348
349pub fn registered_suffixes() -> Vec<&'static str> {
351 SOURCE_ADAPTER_REGISTRY
352 .iter()
353 .map(|registration| registration.suffix)
354 .collect()
355}
356
357#[cfg(test)]
358mod tests;