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
14#[non_exhaustive]
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SourceLanguage {
18 JavaScript,
20 TypeScript,
22 Tsx,
24}
25
26pub(crate) fn grammar_for_language(language: SourceLanguage) -> tree_sitter::Language {
28 match language {
29 SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
30 SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
31 SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SourceFile {
38 pub path: PathBuf,
40 pub language: SourceLanguage,
42}
43
44#[non_exhaustive]
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ExtractionDiagnosticLevel {
48 Warning,
50 Error,
52}
53
54#[non_exhaustive]
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ExtractionDiagnostic {
58 pub level: ExtractionDiagnosticLevel,
60 pub location: SourceLocation,
62 pub message: String,
64}
65
66impl ExtractionDiagnostic {
67 pub fn new(
69 level: ExtractionDiagnosticLevel,
70 location: SourceLocation,
71 message: impl Into<String>,
72 ) -> Self {
73 Self {
74 level,
75 location,
76 message: message.into(),
77 }
78 }
79}
80
81pub(crate) fn is_completeness_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
82 diagnostic
83 .message
84 .starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
85}
86
87#[non_exhaustive]
89#[derive(Debug, Clone, Default, PartialEq)]
90pub struct Extraction {
91 pub definitions: Vec<StepDefinition>,
93 pub diagnostics: Vec<ExtractionDiagnostic>,
95}
96
97impl Extraction {
98 pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
100 Self {
101 definitions,
102 diagnostics,
103 }
104 }
105}
106
107#[derive(Default)]
112pub struct SourceExtractionSession {
113 root: Option<PathBuf>,
114 registrations: Vec<String>,
115 states: HashMap<TypeId, Box<dyn Any + Send + Sync + UnwindSafe + RefUnwindSafe>>,
116}
117
118pub(crate) trait AdapterSessionState:
121 Any + Send + Sync + UnwindSafe + RefUnwindSafe
122{
123 fn initialize(root: Option<&Path>, registrations: &[String]) -> Self;
124}
125
126pub(crate) trait StatefulSourceAdapter: SourceAdapter {
128 type State: AdapterSessionState;
129}
130
131impl SourceExtractionSession {
132 pub fn new(root: &Path) -> Self {
134 Self::with_registrations(root, &[])
135 }
136
137 pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
142 let mut session = Self {
143 root: Some(root.to_owned()),
144 registrations: registrations.to_vec(),
145 ..Self::default()
146 };
147 for registration in SOURCE_ADAPTER_REGISTRY {
149 if let Some(initialize) = registration.initialize_session {
150 initialize(&mut session);
151 }
152 }
153 session
154 }
155
156 pub(crate) fn initialize<T: AdapterSessionState>(&mut self) {
157 self.states
158 .entry(TypeId::of::<T>())
159 .or_insert_with(|| Box::new(T::initialize(self.root.as_deref(), &self.registrations)));
160 }
161
162 pub(crate) fn state<T: AdapterSessionState>(&mut self) -> Result<&mut T> {
163 if self.root.is_none() {
165 self.initialize::<T>();
166 }
167 self.states
168 .get_mut(&TypeId::of::<T>())
169 .and_then(|state| {
170 let state: &mut dyn Any = state.as_mut();
171 state.downcast_mut::<T>()
172 })
173 .with_context(|| {
174 format!(
175 "source adapter state {} is uninitialized or incompatible",
176 std::any::type_name::<T>()
177 )
178 })
179 }
180}
181
182pub trait SourceAdapter: Sync {
184 fn name(&self) -> &'static str;
186
187 fn language(&self) -> SourceLanguage;
189
190 fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
192
193 fn extract_with_session(
195 &self,
196 source: &str,
197 file: &SourceFile,
198 _session: &mut SourceExtractionSession,
199 ) -> Result<Extraction> {
200 self.extract(source, file)
201 }
202
203 fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
205 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
206 self.extract(&source, file)
207 }
208
209 fn extract_file_with_session(
211 &self,
212 file: &SourceFile,
213 session: &mut SourceExtractionSession,
214 ) -> Result<Extraction> {
215 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
216 self.extract_with_session(&source, file, session)
217 }
218}
219
220#[non_exhaustive]
222#[derive(Clone, Copy)]
223pub struct SourceAdapterRegistration {
224 pub suffix: &'static str,
226 pub adapter: &'static dyn SourceAdapter,
228 initialize_session: Option<fn(&mut SourceExtractionSession)>,
229}
230
231impl SourceAdapterRegistration {
232 pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
234 Self {
235 suffix,
236 adapter,
237 initialize_session: None,
238 }
239 }
240
241 const fn with_session<A: StatefulSourceAdapter>(
242 suffix: &'static str,
243 adapter: &'static A,
244 ) -> Self {
245 Self {
246 suffix,
247 adapter,
248 initialize_session: Some(SourceExtractionSession::initialize::<A::State>),
249 }
250 }
251}
252
253macro_rules! register_source_adapters {
258 ( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
259 pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
261 $(
262 $(
263 SourceAdapterRegistration::with_session($suffix, &$adapter),
264 )+
265 )+
266 ];
267
268 pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
270 match language {
271 $(SourceLanguage::$language => &$adapter,)+
272 }
273 }
274 };
275}
276
277register_source_adapters! {
278 JavaScript => crate::typescript::JAVASCRIPT_ADAPTER : ".mjs", ".cjs", ".jsx", ".js";
279 TypeScript => crate::typescript::TYPESCRIPT_ADAPTER : ".mts", ".cts", ".ts";
280 Tsx => crate::typescript::TSX_ADAPTER : ".tsx";
281}
282
283pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
285 let extension = path.extension()?.to_str()?;
286 if extension == "map" || is_declaration_file(path, extension) {
287 return None;
288 }
289 SOURCE_ADAPTER_REGISTRY
290 .iter()
291 .find(|registration| extension == registration.suffix.trim_start_matches('.'))
292 .map(|registration| registration.adapter)
293}
294
295fn is_declaration_file(path: &Path, extension: &str) -> bool {
296 matches!(extension, "ts" | "mts" | "cts")
297 && path.file_stem().is_some_and(|stem| {
298 stem == ".d"
299 || Path::new(stem)
300 .extension()
301 .is_some_and(|extension| extension == "d")
302 })
303}
304
305pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
307 adapter_for_path(path).map(SourceAdapter::language)
308}
309
310pub fn registered_suffixes() -> Vec<&'static str> {
312 SOURCE_ADAPTER_REGISTRY
313 .iter()
314 .map(|registration| registration.suffix)
315 .collect()
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use std::collections::BTreeSet;
322 use std::fs;
323
324 fn session_project() -> tempfile::TempDir {
325 let directory = tempfile::tempdir().unwrap();
326 fs::write(directory.path().join("package.json"), "{}").unwrap();
327 fs::write(
328 directory.path().join("support.ts"),
329 "export { Given } from '@cucumber/cucumber';",
330 )
331 .unwrap();
332 directory
333 }
334
335 fn session_extract(
336 session: &mut SourceExtractionSession,
337 root: &Path,
338 suffix: &str,
339 source: &str,
340 ) -> Extraction {
341 let path = root.join(format!("steps.{suffix}"));
342 let adapter = adapter_for_path(&path).unwrap();
343 adapter
344 .extract_with_session(
345 source,
346 &SourceFile {
347 path,
348 language: adapter.language(),
349 },
350 session,
351 )
352 .unwrap()
353 }
354
355 const IMPORTED_STEP: &str =
356 "import { Given } from './support'; Given('shared step', () => work());";
357
358 #[test]
359 fn session_state_initializes_once_per_backend_and_per_run() {
360 struct Counter(usize);
361 impl AdapterSessionState for Counter {
362 fn initialize(_: Option<&Path>, _: &[String]) -> Self {
363 Self(0)
364 }
365 }
366 struct Other;
367 impl AdapterSessionState for Other {
368 fn initialize(_: Option<&Path>, _: &[String]) -> Self {
369 Self
370 }
371 }
372 let mut session = SourceExtractionSession::default();
373 session.state::<Counter>().unwrap().0 = 7;
374 session.initialize::<Counter>();
375 session.state::<Other>().unwrap();
376 assert_eq!(session.states.len(), 2);
377 assert_eq!(session.state::<Counter>().unwrap().0, 7);
378 assert_eq!(
379 SourceExtractionSession::default()
380 .state::<Counter>()
381 .unwrap()
382 .0,
383 0
384 );
385 let project = tempfile::tempdir().unwrap();
386 let mut explicit = SourceExtractionSession::new(project.path());
387 assert!(explicit.state::<Counter>().is_err());
388 assert!(!explicit.states.contains_key(&TypeId::of::<Counter>()));
389 session
391 .states
392 .insert(TypeId::of::<Counter>(), Box::new(Other));
393 assert!(session.state::<Counter>().is_err());
394 }
395
396 #[test]
397 fn session_mixed_languages_share_cache_and_preserve_findings_in_either_order() {
398 for suffixes in [["js", "ts", "tsx"], ["tsx", "ts", "js"]] {
399 let project = session_project();
400 let root = project.path();
401 let mut session = SourceExtractionSession::new(root);
402 assert_eq!(session.states.len(), 1);
403 let mut definitions = Vec::new();
404 for suffix in suffixes {
405 let extracted = session_extract(&mut session, root, suffix, IMPORTED_STEP);
406 assert_eq!(extracted.definitions.len(), 1, "{suffix}");
407 assert!(extracted.diagnostics.is_empty(), "{suffix}");
408 fs::write(
410 root.join("support.ts"),
411 "export { When } from '@cucumber/cucumber';",
412 )
413 .unwrap();
414 definitions.extend(extracted.definitions);
415 }
416 fs::write(
417 root.join("support.ts"),
418 "export { Given } from '@cucumber/cucumber';",
419 )
420 .unwrap();
421 let mut expected = Vec::new();
422 for suffix in suffixes {
423 let extracted = session_extract(
424 &mut SourceExtractionSession::default(),
425 root,
426 suffix,
427 IMPORTED_STEP,
428 );
429 assert!(extracted.diagnostics.is_empty());
430 expected.extend(extracted.definitions);
431 }
432 assert_eq!(definitions, expected);
433 let config = crate::config::Config::load(root, Default::default()).unwrap();
434 let result = crate::analysis::analyze(definitions, vec![], &config).unwrap();
435 assert_eq!(
436 result
437 .findings
438 .iter()
439 .filter(|finding| finding.rule == crate::model::Rule::DuplicateMatcher)
440 .count(),
441 2
442 );
443 }
444 }
445
446 #[test]
447 fn session_keeps_failed_root_resolution_snapshot_and_default_fallback() {
448 let project = session_project();
449 let missing = project.path().join("initially-missing");
450 let mut session = SourceExtractionSession::new(&missing);
451 fs::create_dir(&missing).unwrap();
452 let actual = session_extract(&mut session, project.path(), "ts", IMPORTED_STEP);
453 let expected = session_extract(
454 &mut SourceExtractionSession::default(),
455 project.path(),
456 "ts",
457 IMPORTED_STEP,
458 );
459 assert_eq!(actual, expected);
460 assert_eq!(actual.definitions.len(), 1);
461 let rejected = session_extract(
462 &mut SourceExtractionSession::new(&missing),
463 project.path(),
464 "ts",
465 IMPORTED_STEP,
466 );
467 assert!(rejected.definitions.is_empty());
468 assert!(!rejected.diagnostics.is_empty());
469 }
470
471 #[cfg(unix)]
472 #[test]
473 fn session_keeps_valid_root_snapshot_when_symlink_is_retargeted() {
474 let original = session_project();
475 let other = session_project();
476 let links = tempfile::tempdir().unwrap();
477 let link = links.path().join("root");
478 std::os::unix::fs::symlink(original.path(), &link).unwrap();
479 let mut session = SourceExtractionSession::new(&link);
480 fs::remove_file(&link).unwrap();
481 std::os::unix::fs::symlink(other.path(), &link).unwrap();
482 let actual = session_extract(&mut session, original.path(), "ts", IMPORTED_STEP);
483 assert_eq!(actual.definitions.len(), 1);
484 assert!(actual.diagnostics.is_empty());
485 let rejected = session_extract(
486 &mut SourceExtractionSession::new(&link),
487 original.path(),
488 "ts",
489 IMPORTED_STEP,
490 );
491 assert!(rejected.definitions.is_empty());
492 assert!(!rejected.diagnostics.is_empty());
493 }
494
495 #[test]
496 fn session_roots_and_configured_registrations_are_isolated() {
497 let project = session_project();
498 let other = session_project();
499 let mut first =
500 SourceExtractionSession::with_registrations(project.path(), &["SetupA".into()]);
501 let mut second =
502 SourceExtractionSession::with_registrations(other.path(), &["SetupB".into()]);
503 let source = "SetupA('custom', () => work()); SetupB('custom', () => work());";
504 for (session, root, name) in [
505 (&mut first, project.path(), "SetupA"),
506 (&mut second, other.path(), "SetupB"),
507 ] {
508 let result = session_extract(session, root, "ts", source);
509 assert_eq!(result.definitions.len(), 1);
510 assert_eq!(result.definitions[0].registration, name);
511 assert!(result.diagnostics.is_empty());
512 }
513 fs::write(
514 other.path().join("support.ts"),
515 "export { When } from '@cucumber/cucumber';",
516 )
517 .unwrap();
518 assert_eq!(
519 session_extract(&mut first, project.path(), "ts", IMPORTED_STEP)
520 .definitions
521 .len(),
522 1
523 );
524 assert!(
525 session_extract(&mut second, other.path(), "ts", IMPORTED_STEP)
526 .definitions
527 .is_empty()
528 );
529 }
530
531 struct DefaultSessionAdapter;
532
533 impl SourceAdapter for DefaultSessionAdapter {
534 fn name(&self) -> &'static str {
535 "default-session-test"
536 }
537
538 fn language(&self) -> SourceLanguage {
539 SourceLanguage::JavaScript
540 }
541
542 fn extract(&self, _source: &str, _file: &SourceFile) -> Result<Extraction> {
543 anyhow::bail!("default extract delegation reached")
544 }
545 }
546
547 #[test]
548 fn registry_routes_supported_suffixes() {
549 let cases = [
550 ("steps.js", SourceLanguage::JavaScript),
551 ("steps.mjs", SourceLanguage::JavaScript),
552 ("steps.cjs", SourceLanguage::JavaScript),
553 ("steps.jsx", SourceLanguage::JavaScript),
554 ("steps.ts", SourceLanguage::TypeScript),
555 ("steps.mts", SourceLanguage::TypeScript),
556 ("steps.cts", SourceLanguage::TypeScript),
557 ("steps.tsx", SourceLanguage::Tsx),
558 ];
559 for (path, expected) in cases {
560 let adapter = adapter_for_path(Path::new(path)).expect(path);
561 assert_eq!(adapter.language(), expected, "{path}");
562 let by_language = adapter_for_language(expected);
563 assert_eq!(by_language.language(), expected, "{path}");
564 assert_eq!(by_language.name(), adapter.name(), "{path}");
565 assert!(!adapter.name().is_empty());
566 }
567 assert_eq!(registered_suffixes().len(), cases.len());
568 }
569
570 #[test]
571 fn source_languages_select_their_registered_tree_sitter_grammar() {
572 let cases = [
573 (SourceLanguage::JavaScript, "const value = 1;"),
574 (SourceLanguage::TypeScript, "const value: number = 1;"),
575 (SourceLanguage::Tsx, "const value = <div />;"),
576 ];
577
578 for (language, source) in cases {
579 let mut parser = tree_sitter::Parser::new();
580 parser
581 .set_language(&grammar_for_language(language))
582 .unwrap();
583 let tree = parser.parse(source, None).unwrap();
584 assert!(!tree.root_node().has_error(), "{language:?}");
585 }
586 }
587
588 #[test]
589 fn every_registration_has_a_unique_suffix_and_produces_shared_ir() {
590 let mut suffixes = BTreeSet::new();
591 for registration in SOURCE_ADAPTER_REGISTRY {
592 assert!(suffixes.insert(registration.suffix), "duplicate suffix");
593 assert!(
594 registration.initialize_session.is_some(),
595 "{} is missing session initialization",
596 registration.suffix
597 );
598 let file = SourceFile {
599 path: PathBuf::from(format!("steps{}", registration.suffix)),
600 language: registration.adapter.language(),
601 };
602 let extraction = registration
603 .adapter
604 .extract("Given('a registered step', () => run())", &file)
605 .unwrap();
606 assert_eq!(extraction.definitions.len(), 1, "{}", registration.suffix);
607 assert!(extraction.diagnostics.is_empty(), "{}", registration.suffix);
608 }
609 }
610
611 #[test]
612 fn registry_rejects_declarations_maps_and_unknown_languages() {
613 for path in [
614 "types.d.ts",
615 "types.d.mts",
616 "types.d.cts",
617 ".d.ts",
618 ".d.mts",
619 ".d.cts",
620 "steps.js.map",
621 "steps.py",
622 ] {
623 assert!(adapter_for_path(Path::new(path)).is_none(), "{path}");
624 }
625 }
626
627 #[cfg(unix)]
628 #[test]
629 fn registry_classifies_non_utf8_names_by_their_ascii_extension() {
630 use std::ffi::OsString;
631 use std::os::unix::ffi::OsStringExt;
632
633 let source = PathBuf::from(OsString::from_vec(b"steps-\xff.ts".to_vec()));
634 assert_eq!(language_for_path(&source), Some(SourceLanguage::TypeScript));
635
636 let declaration = PathBuf::from(OsString::from_vec(b"types-\xff.d.ts".to_vec()));
637 assert!(adapter_for_path(&declaration).is_none());
638 }
639
640 #[test]
641 fn adapter_session_default_preserves_existing_extract_implementations() {
642 let directory = tempfile::tempdir().unwrap();
643 let file = SourceFile {
644 path: directory.path().join("steps.js"),
645 language: SourceLanguage::JavaScript,
646 };
647 let mut session = SourceExtractionSession::new(directory.path());
648
649 let adapter = DefaultSessionAdapter;
650 assert_eq!(adapter.name(), "default-session-test");
651 assert_eq!(adapter.language(), SourceLanguage::JavaScript);
652
653 let error = adapter
654 .extract_with_session("", &file, &mut session)
655 .unwrap_err();
656
657 assert_eq!(error.to_string(), "default extract delegation reached");
658 }
659
660 #[test]
661 fn language_lookup_and_suffix_routing_agree_on_one_adapter_table() {
662 for registration in SOURCE_ADAPTER_REGISTRY {
666 let expected = registration.adapter.language();
667 let name = format!("steps{}", registration.suffix);
668 let routed = adapter_for_path(Path::new(&name)).expect(&name);
669 assert!(
670 std::ptr::addr_eq(
671 routed as *const dyn SourceAdapter,
672 registration.adapter as *const dyn SourceAdapter
673 ),
674 "{}",
675 registration.suffix
676 );
677 let by_language = adapter_for_language(expected);
678 assert!(
679 std::ptr::addr_eq(
680 by_language as *const dyn SourceAdapter,
681 registration.adapter as *const dyn SourceAdapter
682 ),
683 "{}",
684 registration.suffix
685 );
686 assert_eq!(language_for_path(Path::new(&name)), Some(expected));
687 }
688 for language in [
689 SourceLanguage::JavaScript,
690 SourceLanguage::TypeScript,
691 SourceLanguage::Tsx,
692 ] {
693 assert_eq!(adapter_for_language(language).language(), language);
694 }
695 }
696
697 #[test]
698 fn language_routed_and_suffix_routed_extraction_share_session_state() {
699 let project = session_project();
703 for suffix in ["js", "ts", "tsx"] {
704 for language_route_first in [true, false] {
705 let path = project.path().join(format!("steps.{suffix}"));
706 let language = language_for_path(&path).expect(suffix);
707 let file = SourceFile { path, language };
708 let suffix_routed = adapter_for_path(&file.path).expect(suffix);
709 let language_routed = adapter_for_language(language);
710 let mut session = SourceExtractionSession::new(project.path());
711 let (first, second) = if language_route_first {
712 (language_routed, suffix_routed)
713 } else {
714 (suffix_routed, language_routed)
715 };
716 let results = [
717 first.extract_with_session(IMPORTED_STEP, &file, &mut session),
718 second.extract_with_session(IMPORTED_STEP, &file, &mut session),
719 ];
720 let [first, second] = results;
721 let extraction = first.unwrap();
722 assert_eq!(&extraction, second.as_ref().unwrap(), "{suffix}");
723 assert_eq!(extraction.definitions.len(), 1, "{suffix}");
724 assert!(extraction.diagnostics.is_empty(), "{suffix}");
725 assert_eq!(session.states.len(), 1, "{suffix}");
728 }
729 }
730 }
731}