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
20pub(crate) const UNSUPPORTED_MATCHER_DIAGNOSTIC: &str =
22 "dynamic or unsupported step matcher cannot be analyzed statically";
23pub(crate) const INVALID_MATCHER_DIAGNOSTIC: &str =
24 "invalid JavaScript escape sequence in step matcher";
25
26#[non_exhaustive]
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SourceLanguage {
30 JavaScript,
32 TypeScript,
34 Tsx,
36}
37
38pub(crate) fn grammar_for_language(language: SourceLanguage) -> tree_sitter::Language {
40 match language {
41 SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
42 SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
43 SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct SourceFile {
50 pub path: PathBuf,
52 pub language: SourceLanguage,
54}
55
56#[non_exhaustive]
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ExtractionDiagnosticLevel {
60 Warning,
62 Error,
64}
65
66#[non_exhaustive]
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ExtractionDiagnostic {
70 pub level: ExtractionDiagnosticLevel,
72 pub location: SourceLocation,
74 pub message: String,
76}
77
78impl ExtractionDiagnostic {
79 pub fn new(
81 level: ExtractionDiagnosticLevel,
82 location: SourceLocation,
83 message: impl Into<String>,
84 ) -> Self {
85 Self {
86 level,
87 location,
88 message: message.into(),
89 }
90 }
91}
92
93pub(crate) fn is_completeness_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
94 diagnostic
95 .message
96 .starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
97 || matches!(
98 diagnostic.message.as_str(),
99 UNSUPPORTED_MATCHER_DIAGNOSTIC | INVALID_MATCHER_DIAGNOSTIC
100 )
101 || is_unparseable_diagnostic(diagnostic)
102}
103
104pub(crate) fn is_unparseable_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
108 diagnostic
109 .message
110 .starts_with(UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX)
111}
112
113#[non_exhaustive]
115#[derive(Debug, Clone, Default, PartialEq)]
116pub struct Extraction {
117 pub definitions: Vec<StepDefinition>,
119 pub diagnostics: Vec<ExtractionDiagnostic>,
121}
122
123impl Extraction {
124 pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
126 Self {
127 definitions,
128 diagnostics,
129 }
130 }
131}
132
133#[derive(Default)]
138pub struct SourceExtractionSession {
139 root: Option<PathBuf>,
140 registrations: Vec<String>,
141 assertion_modules: Vec<String>,
142 states: HashMap<TypeId, Box<dyn Any + Send + Sync + UnwindSafe + RefUnwindSafe>>,
143}
144
145pub(crate) trait AdapterSessionState:
148 Any + Send + Sync + UnwindSafe + RefUnwindSafe
149{
150 fn initialize(
151 root: Option<&Path>,
152 registrations: &[String],
153 assertion_modules: &[String],
154 ) -> Self;
155}
156
157pub(crate) trait StatefulSourceAdapter: SourceAdapter {
159 type State: AdapterSessionState;
160}
161
162impl SourceExtractionSession {
163 pub fn new(root: &Path) -> Self {
165 Self::with_registrations(root, &[])
166 }
167
168 pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
173 Self::with_options(root, registrations, &[])
174 }
175
176 pub fn with_options(
182 root: &Path,
183 registrations: &[String],
184 assertion_modules: &[String],
185 ) -> Self {
186 let mut session = Self {
187 root: Some(root.to_owned()),
188 registrations: registrations.to_vec(),
189 assertion_modules: assertion_modules.to_vec(),
190 ..Self::default()
191 };
192 for registration in SOURCE_ADAPTER_REGISTRY {
194 if let Some(initialize) = registration.initialize_session {
195 initialize(&mut session);
196 }
197 }
198 session
199 }
200
201 pub(crate) fn initialize<T: AdapterSessionState>(&mut self) {
202 self.states.entry(TypeId::of::<T>()).or_insert_with(|| {
203 Box::new(T::initialize(
204 self.root.as_deref(),
205 &self.registrations,
206 &self.assertion_modules,
207 ))
208 });
209 }
210
211 pub(crate) fn state<T: AdapterSessionState>(&mut self) -> Result<&mut T> {
212 if self.root.is_none() {
214 self.initialize::<T>();
215 }
216 self.states
217 .get_mut(&TypeId::of::<T>())
218 .and_then(|state| {
219 let state: &mut dyn Any = state.as_mut();
220 state.downcast_mut::<T>()
221 })
222 .with_context(|| {
223 format!(
224 "source adapter state {} is uninitialized or incompatible",
225 std::any::type_name::<T>()
226 )
227 })
228 }
229}
230
231pub trait SourceAdapter: Sync {
233 fn name(&self) -> &'static str;
235
236 fn language(&self) -> SourceLanguage;
238
239 fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
241
242 fn extract_with_session(
244 &self,
245 source: &str,
246 file: &SourceFile,
247 _session: &mut SourceExtractionSession,
248 ) -> Result<Extraction> {
249 self.extract(source, file)
250 }
251
252 fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
254 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
255 self.extract(&source, file)
256 }
257
258 fn extract_file_with_session(
260 &self,
261 file: &SourceFile,
262 session: &mut SourceExtractionSession,
263 ) -> Result<Extraction> {
264 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
265 self.extract_with_session(&source, file, session)
266 }
267}
268
269#[non_exhaustive]
271#[derive(Clone, Copy)]
272pub struct SourceAdapterRegistration {
273 pub suffix: &'static str,
275 pub adapter: &'static dyn SourceAdapter,
277 initialize_session: Option<fn(&mut SourceExtractionSession)>,
278}
279
280impl SourceAdapterRegistration {
281 pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
283 Self {
284 suffix,
285 adapter,
286 initialize_session: None,
287 }
288 }
289
290 const fn with_session<A: StatefulSourceAdapter>(
291 suffix: &'static str,
292 adapter: &'static A,
293 ) -> Self {
294 Self {
295 suffix,
296 adapter,
297 initialize_session: Some(SourceExtractionSession::initialize::<A::State>),
298 }
299 }
300}
301
302macro_rules! register_source_adapters {
307 ( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
308 pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
310 $(
311 $(
312 SourceAdapterRegistration::with_session($suffix, &$adapter),
313 )+
314 )+
315 ];
316
317 pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
319 match language {
320 $(SourceLanguage::$language => &$adapter,)+
321 }
322 }
323 };
324}
325
326register_source_adapters! {
327 JavaScript => crate::typescript::JAVASCRIPT_ADAPTER : ".mjs", ".cjs", ".jsx", ".js";
328 TypeScript => crate::typescript::TYPESCRIPT_ADAPTER : ".mts", ".cts", ".ts";
329 Tsx => crate::typescript::TSX_ADAPTER : ".tsx";
330}
331
332pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
334 let extension = path.extension()?.to_str()?;
335 if extension == "map" || is_declaration_file(path, extension) {
336 return None;
337 }
338 SOURCE_ADAPTER_REGISTRY
339 .iter()
340 .find(|registration| extension == registration.suffix.trim_start_matches('.'))
341 .map(|registration| registration.adapter)
342}
343
344fn is_declaration_file(path: &Path, extension: &str) -> bool {
345 matches!(extension, "ts" | "mts" | "cts")
346 && path.file_stem().is_some_and(|stem| {
347 stem == ".d"
348 || Path::new(stem)
349 .extension()
350 .is_some_and(|extension| extension == "d")
351 })
352}
353
354pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
356 adapter_for_path(path).map(SourceAdapter::language)
357}
358
359pub fn registered_suffixes() -> Vec<&'static str> {
361 SOURCE_ADAPTER_REGISTRY
362 .iter()
363 .map(|registration| registration.suffix)
364 .collect()
365}
366
367#[cfg(test)]
368mod tests;