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    assertion_modules: Vec<String>,
116    states: HashMap<TypeId, Box<dyn Any + Send + Sync + UnwindSafe + RefUnwindSafe>>,
117}
118
119/// Backend-owned state; related syntax variants can share the same state type.
120/// The bounds preserve the public session's existing thread and unwind-safety guarantees.
121pub(crate) trait AdapterSessionState:
122    Any + Send + Sync + UnwindSafe + RefUnwindSafe
123{
124    fn initialize(
125        root: Option<&Path>,
126        registrations: &[String],
127        assertion_modules: &[String],
128    ) -> Self;
129}
130
131/// Internal stateful registrations must explicitly select their backend state.
132pub(crate) trait StatefulSourceAdapter: SourceAdapter {
133    type State: AdapterSessionState;
134}
135
136impl SourceExtractionSession {
137    /// Creates an extraction session whose imported modules must remain inside `root`.
138    pub fn new(root: &Path) -> Self {
139        Self::with_registrations(root, &[])
140    }
141
142    /// Creates a session that also treats `registrations` as step-registration function names.
143    ///
144    /// Use this for project-declared wrappers that static inference cannot recognize, such as a
145    /// helper that builds its registration call dynamically.
146    pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
147        Self::with_options(root, registrations, &[])
148    }
149
150    /// Creates a session that also trusts `assertion_modules` as assertion-factory providers.
151    ///
152    /// Use this for a local module that re-exports `expect` from a supported assertion package, or
153    /// for a runner this build does not recognize by name. Without it such a factory is untrusted
154    /// and its expected values are not treated as behaviour.
155    pub fn with_options(
156        root: &Path,
157        registrations: &[String],
158        assertion_modules: &[String],
159    ) -> Self {
160        let mut session = Self {
161            root: Some(root.to_owned()),
162            registrations: registrations.to_vec(),
163            assertion_modules: assertion_modules.to_vec(),
164            ..Self::default()
165        };
166        // Preserve construction-time root resolution, including a failed canonicalization.
167        for registration in SOURCE_ADAPTER_REGISTRY {
168            if let Some(initialize) = registration.initialize_session {
169                initialize(&mut session);
170            }
171        }
172        session
173    }
174
175    pub(crate) fn initialize<T: AdapterSessionState>(&mut self) {
176        self.states.entry(TypeId::of::<T>()).or_insert_with(|| {
177            Box::new(T::initialize(
178                self.root.as_deref(),
179                &self.registrations,
180                &self.assertion_modules,
181            ))
182        });
183    }
184
185    pub(crate) fn state<T: AdapterSessionState>(&mut self) -> Result<&mut T> {
186        // Explicit-root sessions must never establish a boundary later than construction.
187        if self.root.is_none() {
188            self.initialize::<T>();
189        }
190        self.states
191            .get_mut(&TypeId::of::<T>())
192            .and_then(|state| {
193                let state: &mut dyn Any = state.as_mut();
194                state.downcast_mut::<T>()
195            })
196            .with_context(|| {
197                format!(
198                    "source adapter state {} is uninitialized or incompatible",
199                    std::any::type_name::<T>()
200                )
201            })
202    }
203}
204
205/// Converts one supported definition-source language into the shared definition IR.
206pub trait SourceAdapter: Sync {
207    /// Stable adapter name used in diagnostics and registry inspection.
208    fn name(&self) -> &'static str;
209
210    /// Parser language produced by this adapter registration.
211    fn language(&self) -> SourceLanguage;
212
213    /// Extracts definitions from in-memory source using `file` for source locations.
214    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
215
216    /// Extracts definitions while sharing bounded state with other files in the same run.
217    fn extract_with_session(
218        &self,
219        source: &str,
220        file: &SourceFile,
221        _session: &mut SourceExtractionSession,
222    ) -> Result<Extraction> {
223        self.extract(source, file)
224    }
225
226    /// Reads and extracts one source file.
227    fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
228        let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
229        self.extract(&source, file)
230    }
231
232    /// Reads and extracts one source file using shared run state.
233    fn extract_file_with_session(
234        &self,
235        file: &SourceFile,
236        session: &mut SourceExtractionSession,
237    ) -> Result<Extraction> {
238        let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
239        self.extract_with_session(&source, file, session)
240    }
241}
242
243/// One suffix-to-adapter registration.
244#[non_exhaustive]
245#[derive(Clone, Copy)]
246pub struct SourceAdapterRegistration {
247    /// Filename suffix, including the leading dot.
248    pub suffix: &'static str,
249    /// Adapter responsible for matching sources.
250    pub adapter: &'static dyn SourceAdapter,
251    initialize_session: Option<fn(&mut SourceExtractionSession)>,
252}
253
254impl SourceAdapterRegistration {
255    /// Creates one suffix-to-adapter registration.
256    pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
257        Self {
258            suffix,
259            adapter,
260            initialize_session: None,
261        }
262    }
263
264    const fn with_session<A: StatefulSourceAdapter>(
265        suffix: &'static str,
266        adapter: &'static A,
267    ) -> Self {
268        Self {
269            suffix,
270            adapter,
271            initialize_session: Some(SourceExtractionSession::initialize::<A::State>),
272        }
273    }
274}
275
276/// The authoritative language/adapter mapping: one table drives both the suffix registry and the
277/// language lookup, so the two routing surfaces cannot drift apart. Adding a language means one
278/// row here; the expansion stays an exhaustive match, so routing remains total without a
279/// fallible lookup.
280macro_rules! register_source_adapters {
281    ( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
282        /// Registered definition-source suffixes in deterministic lookup order.
283        pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
284            $(
285                $(
286                    SourceAdapterRegistration::with_session($suffix, &$adapter),
287                )+
288            )+
289        ];
290
291        /// Returns the adapter for an already classified source language.
292        pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
293            match language {
294                $(SourceLanguage::$language => &$adapter,)+
295            }
296        }
297    };
298}
299
300register_source_adapters! {
301    JavaScript => crate::typescript::JAVASCRIPT_ADAPTER : ".mjs", ".cjs", ".jsx", ".js";
302    TypeScript => crate::typescript::TYPESCRIPT_ADAPTER : ".mts", ".cts", ".ts";
303    Tsx => crate::typescript::TSX_ADAPTER : ".tsx";
304}
305
306/// Returns the adapter registered for `path`, rejecting declaration and source-map files.
307pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
308    let extension = path.extension()?.to_str()?;
309    if extension == "map" || is_declaration_file(path, extension) {
310        return None;
311    }
312    SOURCE_ADAPTER_REGISTRY
313        .iter()
314        .find(|registration| extension == registration.suffix.trim_start_matches('.'))
315        .map(|registration| registration.adapter)
316}
317
318fn is_declaration_file(path: &Path, extension: &str) -> bool {
319    matches!(extension, "ts" | "mts" | "cts")
320        && path.file_stem().is_some_and(|stem| {
321            stem == ".d"
322                || Path::new(stem)
323                    .extension()
324                    .is_some_and(|extension| extension == "d")
325        })
326}
327
328/// Classifies a path using the registered suffixes.
329pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
330    adapter_for_path(path).map(SourceAdapter::language)
331}
332
333/// Returns the registered suffixes for tooling and adapter conformance tests.
334pub fn registered_suffixes() -> Vec<&'static str> {
335    SOURCE_ADAPTER_REGISTRY
336        .iter()
337        .map(|registration| registration.suffix)
338        .collect()
339}
340
341#[cfg(test)]
342mod tests;