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::Result;
6use std::path::{Path, PathBuf};
7
8pub(crate) const UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX: &str =
9    "unresolved step-registration calls:";
10
11/// Parser language selected for a definition source.
12#[non_exhaustive]
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SourceLanguage {
15    /// JavaScript, JSX, or their module variants.
16    JavaScript,
17    /// TypeScript or its module variants.
18    TypeScript,
19    /// TypeScript with JSX syntax.
20    Tsx,
21}
22
23/// Returns the tree-sitter grammar registered for a definition-source language.
24pub(crate) fn grammar_for_language(language: SourceLanguage) -> tree_sitter::Language {
25    match language {
26        SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
27        SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
28        SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
29    }
30}
31
32/// A discovered definition source and the language used to parse it.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SourceFile {
35    /// Absolute source path.
36    pub path: PathBuf,
37    /// Parser language inferred from the registered extension.
38    pub language: SourceLanguage,
39}
40
41/// Impact of a source-extraction diagnostic.
42#[non_exhaustive]
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ExtractionDiagnosticLevel {
45    /// Analysis continues, but a static-analysis limitation applies.
46    Warning,
47    /// The source contains a malformed step registration.
48    Error,
49}
50
51/// A source-localized problem encountered while extracting definitions.
52#[non_exhaustive]
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ExtractionDiagnostic {
55    /// Diagnostic impact.
56    pub level: ExtractionDiagnosticLevel,
57    /// Source position of the affected matcher.
58    pub location: SourceLocation,
59    /// Human-readable explanation.
60    pub message: String,
61}
62
63impl ExtractionDiagnostic {
64    /// Creates a source-localized extraction diagnostic.
65    pub fn new(
66        level: ExtractionDiagnosticLevel,
67        location: SourceLocation,
68        message: impl Into<String>,
69    ) -> Self {
70        Self {
71            level,
72            location,
73            message: message.into(),
74        }
75    }
76}
77
78pub(crate) fn is_completeness_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
79    diagnostic
80        .message
81        .starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
82}
83
84/// Definitions and non-fatal diagnostics extracted from one source.
85#[non_exhaustive]
86#[derive(Debug, Clone, Default, PartialEq)]
87pub struct Extraction {
88    /// Successfully extracted definitions.
89    pub definitions: Vec<StepDefinition>,
90    /// Problems that did not prevent extraction of the rest of the file.
91    pub diagnostics: Vec<ExtractionDiagnostic>,
92}
93
94impl Extraction {
95    /// Creates an extraction result from definitions and non-fatal diagnostics.
96    pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
97        Self {
98            definitions,
99            diagnostics,
100        }
101    }
102}
103
104/// Reusable state for extracting multiple definition sources in one analysis run.
105///
106/// Reusing a session shares bounded parser and module-resolution work across files. Create one
107/// session per analysis root rather than sharing it between unrelated repositories.
108#[derive(Default)]
109pub struct SourceExtractionSession {
110    pub(crate) typescript: crate::typescript::TypeScriptExtractionSession,
111}
112
113impl SourceExtractionSession {
114    /// Creates an extraction session whose imported modules must remain inside `root`.
115    pub fn new(root: &Path) -> Self {
116        Self::with_registrations(root, &[])
117    }
118
119    /// Creates a session that also treats `registrations` as step-registration function names.
120    ///
121    /// Use this for project-declared wrappers that static inference cannot recognize, such as a
122    /// helper that builds its registration call dynamically.
123    pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
124        Self {
125            typescript: crate::typescript::TypeScriptExtractionSession::for_root(
126                root,
127                registrations,
128            ),
129        }
130    }
131}
132
133/// Converts one supported definition-source language into the shared definition IR.
134pub trait SourceAdapter: Sync {
135    /// Stable adapter name used in diagnostics and registry inspection.
136    fn name(&self) -> &'static str;
137
138    /// Parser language produced by this adapter registration.
139    fn language(&self) -> SourceLanguage;
140
141    /// Extracts definitions from in-memory source using `file` for source locations.
142    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
143
144    /// Extracts definitions while sharing bounded state with other files in the same run.
145    fn extract_with_session(
146        &self,
147        source: &str,
148        file: &SourceFile,
149        _session: &mut SourceExtractionSession,
150    ) -> Result<Extraction> {
151        self.extract(source, file)
152    }
153
154    /// Reads and extracts one source file.
155    fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
156        let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
157        self.extract(&source, file)
158    }
159
160    /// Reads and extracts one source file using shared run state.
161    fn extract_file_with_session(
162        &self,
163        file: &SourceFile,
164        session: &mut SourceExtractionSession,
165    ) -> Result<Extraction> {
166        let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
167        self.extract_with_session(&source, file, session)
168    }
169}
170
171/// One suffix-to-adapter registration.
172#[non_exhaustive]
173#[derive(Clone, Copy)]
174pub struct SourceAdapterRegistration {
175    /// Filename suffix, including the leading dot.
176    pub suffix: &'static str,
177    /// Adapter responsible for matching sources.
178    pub adapter: &'static dyn SourceAdapter,
179}
180
181impl SourceAdapterRegistration {
182    /// Creates one suffix-to-adapter registration.
183    pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
184        Self { suffix, adapter }
185    }
186}
187
188/// Registered definition-source suffixes in deterministic lookup order.
189pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
190    SourceAdapterRegistration::new(".mjs", &crate::typescript::JAVASCRIPT_ADAPTER),
191    SourceAdapterRegistration::new(".cjs", &crate::typescript::JAVASCRIPT_ADAPTER),
192    SourceAdapterRegistration::new(".jsx", &crate::typescript::JAVASCRIPT_ADAPTER),
193    SourceAdapterRegistration::new(".js", &crate::typescript::JAVASCRIPT_ADAPTER),
194    SourceAdapterRegistration::new(".mts", &crate::typescript::TYPESCRIPT_ADAPTER),
195    SourceAdapterRegistration::new(".cts", &crate::typescript::TYPESCRIPT_ADAPTER),
196    SourceAdapterRegistration::new(".tsx", &crate::typescript::TSX_ADAPTER),
197    SourceAdapterRegistration::new(".ts", &crate::typescript::TYPESCRIPT_ADAPTER),
198];
199
200/// Returns the adapter registered for `path`, rejecting declaration and source-map files.
201pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
202    let extension = path.extension()?.to_str()?;
203    if extension == "map" || is_declaration_file(path, extension) {
204        return None;
205    }
206    SOURCE_ADAPTER_REGISTRY
207        .iter()
208        .find(|registration| extension == registration.suffix.trim_start_matches('.'))
209        .map(|registration| registration.adapter)
210}
211
212fn is_declaration_file(path: &Path, extension: &str) -> bool {
213    matches!(extension, "ts" | "mts" | "cts")
214        && path.file_stem().is_some_and(|stem| {
215            stem == ".d"
216                || Path::new(stem)
217                    .extension()
218                    .is_some_and(|extension| extension == "d")
219        })
220}
221
222/// Returns the adapter for an already classified source language.
223pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
224    match language {
225        SourceLanguage::JavaScript => &crate::typescript::JAVASCRIPT_ADAPTER,
226        SourceLanguage::TypeScript => &crate::typescript::TYPESCRIPT_ADAPTER,
227        SourceLanguage::Tsx => &crate::typescript::TSX_ADAPTER,
228    }
229}
230
231/// Classifies a path using the registered suffixes.
232pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
233    adapter_for_path(path).map(SourceAdapter::language)
234}
235
236/// Returns the registered suffixes for tooling and adapter conformance tests.
237pub fn registered_suffixes() -> Vec<&'static str> {
238    SOURCE_ADAPTER_REGISTRY
239        .iter()
240        .map(|registration| registration.suffix)
241        .collect()
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use std::collections::BTreeSet;
248
249    struct DefaultSessionAdapter;
250
251    impl SourceAdapter for DefaultSessionAdapter {
252        fn name(&self) -> &'static str {
253            "default-session-test"
254        }
255
256        fn language(&self) -> SourceLanguage {
257            SourceLanguage::JavaScript
258        }
259
260        fn extract(&self, _source: &str, _file: &SourceFile) -> Result<Extraction> {
261            anyhow::bail!("default extract delegation reached")
262        }
263    }
264
265    #[test]
266    fn registry_routes_supported_suffixes() {
267        let cases = [
268            ("steps.js", SourceLanguage::JavaScript),
269            ("steps.mjs", SourceLanguage::JavaScript),
270            ("steps.cjs", SourceLanguage::JavaScript),
271            ("steps.jsx", SourceLanguage::JavaScript),
272            ("steps.ts", SourceLanguage::TypeScript),
273            ("steps.mts", SourceLanguage::TypeScript),
274            ("steps.cts", SourceLanguage::TypeScript),
275            ("steps.tsx", SourceLanguage::Tsx),
276        ];
277        for (path, expected) in cases {
278            let adapter = adapter_for_path(Path::new(path)).expect(path);
279            assert_eq!(adapter.language(), expected, "{path}");
280            assert!(!adapter.name().is_empty());
281        }
282        assert_eq!(registered_suffixes().len(), cases.len());
283    }
284
285    #[test]
286    fn source_languages_select_their_registered_tree_sitter_grammar() {
287        let cases = [
288            (SourceLanguage::JavaScript, "const value = 1;"),
289            (SourceLanguage::TypeScript, "const value: number = 1;"),
290            (SourceLanguage::Tsx, "const value = <div />;"),
291        ];
292
293        for (language, source) in cases {
294            let mut parser = tree_sitter::Parser::new();
295            parser
296                .set_language(&grammar_for_language(language))
297                .unwrap();
298            let tree = parser.parse(source, None).unwrap();
299            assert!(!tree.root_node().has_error(), "{language:?}");
300        }
301    }
302
303    #[test]
304    fn every_registration_has_a_unique_suffix_and_produces_shared_ir() {
305        let mut suffixes = BTreeSet::new();
306        for registration in SOURCE_ADAPTER_REGISTRY {
307            assert!(suffixes.insert(registration.suffix), "duplicate suffix");
308            let file = SourceFile {
309                path: PathBuf::from(format!("steps{}", registration.suffix)),
310                language: registration.adapter.language(),
311            };
312            let extraction = registration
313                .adapter
314                .extract("Given('a registered step', () => run())", &file)
315                .unwrap();
316            assert_eq!(extraction.definitions.len(), 1, "{}", registration.suffix);
317            assert!(extraction.diagnostics.is_empty(), "{}", registration.suffix);
318        }
319    }
320
321    #[test]
322    fn registry_rejects_declarations_maps_and_unknown_languages() {
323        for path in [
324            "types.d.ts",
325            "types.d.mts",
326            "types.d.cts",
327            ".d.ts",
328            ".d.mts",
329            ".d.cts",
330            "steps.js.map",
331            "steps.py",
332        ] {
333            assert!(adapter_for_path(Path::new(path)).is_none(), "{path}");
334        }
335    }
336
337    #[cfg(unix)]
338    #[test]
339    fn registry_classifies_non_utf8_names_by_their_ascii_extension() {
340        use std::ffi::OsString;
341        use std::os::unix::ffi::OsStringExt;
342
343        let source = PathBuf::from(OsString::from_vec(b"steps-\xff.ts".to_vec()));
344        assert_eq!(language_for_path(&source), Some(SourceLanguage::TypeScript));
345
346        let declaration = PathBuf::from(OsString::from_vec(b"types-\xff.d.ts".to_vec()));
347        assert!(adapter_for_path(&declaration).is_none());
348    }
349
350    #[test]
351    fn adapter_session_default_preserves_existing_extract_implementations() {
352        let directory = tempfile::tempdir().unwrap();
353        let file = SourceFile {
354            path: directory.path().join("steps.js"),
355            language: SourceLanguage::JavaScript,
356        };
357        let mut session = SourceExtractionSession::new(directory.path());
358
359        let adapter = DefaultSessionAdapter;
360        assert_eq!(adapter.name(), "default-session-test");
361        assert_eq!(adapter.language(), SourceLanguage::JavaScript);
362
363        let error = adapter
364            .extract_with_session("", &file, &mut session)
365            .unwrap_err();
366
367        assert_eq!(error.to_string(), "default extract delegation reached");
368    }
369}