Skip to main content

cuke_dedup/
source_adapter.rs

1//! Definition-source adapter contracts and extension-based routing.
2
3use 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/// Parser language selected for a definition source.
15#[non_exhaustive]
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SourceLanguage {
18    /// JavaScript, JSX, or their module variants.
19    JavaScript,
20    /// TypeScript or its module variants.
21    TypeScript,
22    /// TypeScript with JSX syntax.
23    Tsx,
24}
25
26/// Returns the tree-sitter grammar registered for a definition-source language.
27pub(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/// A discovered definition source and the language used to parse it.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SourceFile {
38    /// Absolute source path.
39    pub path: PathBuf,
40    /// Parser language inferred from the registered extension.
41    pub language: SourceLanguage,
42}
43
44/// Impact of a source-extraction diagnostic.
45#[non_exhaustive]
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ExtractionDiagnosticLevel {
48    /// Analysis continues, but a static-analysis limitation applies.
49    Warning,
50    /// The source contains a malformed step registration.
51    Error,
52}
53
54/// A source-localized problem encountered while extracting definitions.
55#[non_exhaustive]
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ExtractionDiagnostic {
58    /// Diagnostic impact.
59    pub level: ExtractionDiagnosticLevel,
60    /// Source position of the affected matcher.
61    pub location: SourceLocation,
62    /// Human-readable explanation.
63    pub message: String,
64}
65
66impl ExtractionDiagnostic {
67    /// Creates a source-localized extraction diagnostic.
68    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/// Definitions and non-fatal diagnostics extracted from one source.
88#[non_exhaustive]
89#[derive(Debug, Clone, Default, PartialEq)]
90pub struct Extraction {
91    /// Successfully extracted definitions.
92    pub definitions: Vec<StepDefinition>,
93    /// Problems that did not prevent extraction of the rest of the file.
94    pub diagnostics: Vec<ExtractionDiagnostic>,
95}
96
97impl Extraction {
98    /// Creates an extraction result from definitions and non-fatal diagnostics.
99    pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
100        Self {
101            definitions,
102            diagnostics,
103        }
104    }
105}
106
107/// Reusable state for extracting multiple definition sources in one analysis run.
108///
109/// Reusing a session shares bounded parser and module-resolution work across files. Create one
110/// session per analysis root rather than sharing it between unrelated repositories.
111#[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
118/// Backend-owned state; related syntax variants can share the same state type.
119/// The bounds preserve the public session's existing thread and unwind-safety guarantees.
120pub(crate) trait AdapterSessionState:
121    Any + Send + Sync + UnwindSafe + RefUnwindSafe
122{
123    fn initialize(root: Option<&Path>, registrations: &[String]) -> Self;
124}
125
126/// Internal stateful registrations must explicitly select their backend state.
127pub(crate) trait StatefulSourceAdapter: SourceAdapter {
128    type State: AdapterSessionState;
129}
130
131impl SourceExtractionSession {
132    /// Creates an extraction session whose imported modules must remain inside `root`.
133    pub fn new(root: &Path) -> Self {
134        Self::with_registrations(root, &[])
135    }
136
137    /// Creates a session that also treats `registrations` as step-registration function names.
138    ///
139    /// Use this for project-declared wrappers that static inference cannot recognize, such as a
140    /// helper that builds its registration call dynamically.
141    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        // Preserve construction-time root resolution, including a failed canonicalization.
148        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        // Explicit-root sessions must never establish a boundary later than construction.
164        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
182/// Converts one supported definition-source language into the shared definition IR.
183pub trait SourceAdapter: Sync {
184    /// Stable adapter name used in diagnostics and registry inspection.
185    fn name(&self) -> &'static str;
186
187    /// Parser language produced by this adapter registration.
188    fn language(&self) -> SourceLanguage;
189
190    /// Extracts definitions from in-memory source using `file` for source locations.
191    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
192
193    /// Extracts definitions while sharing bounded state with other files in the same run.
194    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    /// Reads and extracts one source file.
204    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    /// Reads and extracts one source file using shared run state.
210    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/// One suffix-to-adapter registration.
221#[non_exhaustive]
222#[derive(Clone, Copy)]
223pub struct SourceAdapterRegistration {
224    /// Filename suffix, including the leading dot.
225    pub suffix: &'static str,
226    /// Adapter responsible for matching sources.
227    pub adapter: &'static dyn SourceAdapter,
228    initialize_session: Option<fn(&mut SourceExtractionSession)>,
229}
230
231impl SourceAdapterRegistration {
232    /// Creates one suffix-to-adapter registration.
233    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
253/// The authoritative language/adapter mapping: one table drives both the suffix registry and the
254/// language lookup, so the two routing surfaces cannot drift apart. Adding a language means one
255/// row here; the expansion stays an exhaustive match, so routing remains total without a
256/// fallible lookup.
257macro_rules! register_source_adapters {
258    ( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
259        /// Registered definition-source suffixes in deterministic lookup order.
260        pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
261            $(
262                $(
263                    SourceAdapterRegistration::with_session($suffix, &$adapter),
264                )+
265            )+
266        ];
267
268        /// Returns the adapter for an already classified source language.
269        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
283/// Returns the adapter registered for `path`, rejecting declaration and source-map files.
284pub 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
305/// Classifies a path using the registered suffixes.
306pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
307    adapter_for_path(path).map(SourceAdapter::language)
308}
309
310/// Returns the registered suffixes for tooling and adapter conformance tests.
311pub 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        // Even a violated internal key/value invariant must return an error, not panic.
390        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                // A second resolver would observe this edit; a shared run keeps cached exports.
409                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        // Data-address identity (not `ptr::eq`, which compares wide-pointer vtables) is what
663        // "one authoritative mapping" means here: both routing surfaces must select the same
664        // adapter static, not merely two adapters that agree on name and language.
665        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        // The registry's session initializers must prepare exactly the backend state that
700        // `adapter_for_language` routes to: the same resolver-backed import has to extract
701        // identically through either surface, whichever warms the shared cache first.
702        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                // Every registered adapter is TypeScript-backed today, so one shared backend
726                // state must serve all of them; a second backend is allowed to change this.
727                assert_eq!(session.states.len(), 1, "{suffix}");
728            }
729        }
730    }
731}