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/// Prefix of the diagnostic emitted when a source file cannot be parsed as valid JavaScript or
15/// TypeScript. A parse failure hides every definition the file would have contributed, so it is a
16/// completeness signal; `--fail-on-unparseable` promotes it to a hard error.
17pub(crate) const UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX: &str =
18    "source contains JavaScript/TypeScript syntax errors";
19
20/// A recognized registration cannot contribute a statically known matcher.
21pub(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/// Parser language selected for a definition source.
27#[non_exhaustive]
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SourceLanguage {
30    /// JavaScript, JSX, or their module variants.
31    JavaScript,
32    /// TypeScript or its module variants.
33    TypeScript,
34    /// TypeScript with JSX syntax.
35    Tsx,
36}
37
38/// Returns the tree-sitter grammar registered for a definition-source language.
39pub(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/// A discovered definition source and the language used to parse it.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct SourceFile {
50    /// Absolute source path.
51    pub path: PathBuf,
52    /// Parser language inferred from the registered extension.
53    pub language: SourceLanguage,
54}
55
56/// Impact of a source-extraction diagnostic.
57#[non_exhaustive]
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ExtractionDiagnosticLevel {
60    /// Analysis continues, but a static-analysis limitation applies.
61    Warning,
62    /// The source contains a malformed step registration.
63    Error,
64}
65
66/// A source-localized problem encountered while extracting definitions.
67#[non_exhaustive]
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ExtractionDiagnostic {
70    /// Diagnostic impact.
71    pub level: ExtractionDiagnosticLevel,
72    /// Source position of the affected matcher.
73    pub location: SourceLocation,
74    /// Human-readable explanation.
75    pub message: String,
76}
77
78impl ExtractionDiagnostic {
79    /// Creates a source-localized extraction diagnostic.
80    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
104/// Reports whether a diagnostic marks a source that could not be parsed at all. Distinct from the
105/// broader completeness check so `--fail-on-unparseable` can gate this one case without also
106/// forcing every other incompleteness to abort.
107pub(crate) fn is_unparseable_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
108    diagnostic
109        .message
110        .starts_with(UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX)
111}
112
113/// Definitions and non-fatal diagnostics extracted from one source.
114#[non_exhaustive]
115#[derive(Debug, Clone, Default, PartialEq)]
116pub struct Extraction {
117    /// Successfully extracted definitions.
118    pub definitions: Vec<StepDefinition>,
119    /// Problems that did not prevent extraction of the rest of the file.
120    pub diagnostics: Vec<ExtractionDiagnostic>,
121}
122
123impl Extraction {
124    /// Creates an extraction result from definitions and non-fatal diagnostics.
125    pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
126        Self {
127            definitions,
128            diagnostics,
129        }
130    }
131}
132
133/// Reusable state for extracting multiple definition sources in one analysis run.
134///
135/// Reusing a session shares bounded parser and module-resolution work across files. Create one
136/// session per analysis root rather than sharing it between unrelated repositories.
137#[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
145/// Backend-owned state; related syntax variants can share the same state type.
146/// The bounds preserve the public session's existing thread and unwind-safety guarantees.
147pub(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
157/// Internal stateful registrations must explicitly select their backend state.
158pub(crate) trait StatefulSourceAdapter: SourceAdapter {
159    type State: AdapterSessionState;
160}
161
162impl SourceExtractionSession {
163    /// Creates an extraction session whose imported modules must remain inside `root`.
164    pub fn new(root: &Path) -> Self {
165        Self::with_registrations(root, &[])
166    }
167
168    /// Creates a session that also treats `registrations` as step-registration function names.
169    ///
170    /// Use this for project-declared wrappers that static inference cannot recognize, such as a
171    /// helper that builds its registration call dynamically.
172    pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
173        Self::with_options(root, registrations, &[])
174    }
175
176    /// Creates a session that also trusts `assertion_modules` as assertion-factory providers.
177    ///
178    /// Use this for a local module that re-exports `expect` from a supported assertion package, or
179    /// for a runner this build does not recognize by name. Without it such a factory is untrusted
180    /// and its expected values are not treated as behaviour.
181    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        // Preserve construction-time root resolution, including a failed canonicalization.
193        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        // Explicit-root sessions must never establish a boundary later than construction.
213        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
231/// Converts one supported definition-source language into the shared definition IR.
232pub trait SourceAdapter: Sync {
233    /// Stable adapter name used in diagnostics and registry inspection.
234    fn name(&self) -> &'static str;
235
236    /// Parser language produced by this adapter registration.
237    fn language(&self) -> SourceLanguage;
238
239    /// Extracts definitions from in-memory source using `file` for source locations.
240    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
241
242    /// Extracts definitions while sharing bounded state with other files in the same run.
243    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    /// Reads and extracts one source file.
253    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    /// Reads and extracts one source file using shared run state.
259    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/// One suffix-to-adapter registration.
270#[non_exhaustive]
271#[derive(Clone, Copy)]
272pub struct SourceAdapterRegistration {
273    /// Filename suffix, including the leading dot.
274    pub suffix: &'static str,
275    /// Adapter responsible for matching sources.
276    pub adapter: &'static dyn SourceAdapter,
277    initialize_session: Option<fn(&mut SourceExtractionSession)>,
278}
279
280impl SourceAdapterRegistration {
281    /// Creates one suffix-to-adapter registration.
282    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
302/// The authoritative language/adapter mapping: one table drives both the suffix registry and the
303/// language lookup, so the two routing surfaces cannot drift apart. Adding a language means one
304/// row here; the expansion stays an exhaustive match, so routing remains total without a
305/// fallible lookup.
306macro_rules! register_source_adapters {
307    ( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
308        /// Registered definition-source suffixes in deterministic lookup order.
309        pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
310            $(
311                $(
312                    SourceAdapterRegistration::with_session($suffix, &$adapter),
313                )+
314            )+
315        ];
316
317        /// Returns the adapter for an already classified source language.
318        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
332/// Returns the adapter registered for `path`, rejecting declaration and source-map files.
333pub 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
354/// Classifies a path using the registered suffixes.
355pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
356    adapter_for_path(path).map(SourceAdapter::language)
357}
358
359/// Returns the registered suffixes for tooling and adapter conformance tests.
360pub 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;