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/// Parser language selected for a definition source.
21#[non_exhaustive]
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SourceLanguage {
24    /// JavaScript, JSX, or their module variants.
25    JavaScript,
26    /// TypeScript or its module variants.
27    TypeScript,
28    /// TypeScript with JSX syntax.
29    Tsx,
30}
31
32/// Returns the tree-sitter grammar registered for a definition-source language.
33pub(crate) fn grammar_for_language(language: SourceLanguage) -> tree_sitter::Language {
34    match language {
35        SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
36        SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
37        SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
38    }
39}
40
41/// A discovered definition source and the language used to parse it.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SourceFile {
44    /// Absolute source path.
45    pub path: PathBuf,
46    /// Parser language inferred from the registered extension.
47    pub language: SourceLanguage,
48}
49
50/// Impact of a source-extraction diagnostic.
51#[non_exhaustive]
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ExtractionDiagnosticLevel {
54    /// Analysis continues, but a static-analysis limitation applies.
55    Warning,
56    /// The source contains a malformed step registration.
57    Error,
58}
59
60/// A source-localized problem encountered while extracting definitions.
61#[non_exhaustive]
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ExtractionDiagnostic {
64    /// Diagnostic impact.
65    pub level: ExtractionDiagnosticLevel,
66    /// Source position of the affected matcher.
67    pub location: SourceLocation,
68    /// Human-readable explanation.
69    pub message: String,
70}
71
72impl ExtractionDiagnostic {
73    /// Creates a source-localized extraction diagnostic.
74    pub fn new(
75        level: ExtractionDiagnosticLevel,
76        location: SourceLocation,
77        message: impl Into<String>,
78    ) -> Self {
79        Self {
80            level,
81            location,
82            message: message.into(),
83        }
84    }
85}
86
87pub(crate) fn is_completeness_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
88    diagnostic
89        .message
90        .starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
91        || is_unparseable_diagnostic(diagnostic)
92}
93
94/// Reports whether a diagnostic marks a source that could not be parsed at all. Distinct from the
95/// broader completeness check so `--fail-on-unparseable` can gate this one case without also
96/// forcing every other incompleteness to abort.
97pub(crate) fn is_unparseable_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
98    diagnostic
99        .message
100        .starts_with(UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX)
101}
102
103/// Definitions and non-fatal diagnostics extracted from one source.
104#[non_exhaustive]
105#[derive(Debug, Clone, Default, PartialEq)]
106pub struct Extraction {
107    /// Successfully extracted definitions.
108    pub definitions: Vec<StepDefinition>,
109    /// Problems that did not prevent extraction of the rest of the file.
110    pub diagnostics: Vec<ExtractionDiagnostic>,
111}
112
113impl Extraction {
114    /// Creates an extraction result from definitions and non-fatal diagnostics.
115    pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
116        Self {
117            definitions,
118            diagnostics,
119        }
120    }
121}
122
123/// Reusable state for extracting multiple definition sources in one analysis run.
124///
125/// Reusing a session shares bounded parser and module-resolution work across files. Create one
126/// session per analysis root rather than sharing it between unrelated repositories.
127#[derive(Default)]
128pub struct SourceExtractionSession {
129    root: Option<PathBuf>,
130    registrations: Vec<String>,
131    assertion_modules: Vec<String>,
132    states: HashMap<TypeId, Box<dyn Any + Send + Sync + UnwindSafe + RefUnwindSafe>>,
133}
134
135/// Backend-owned state; related syntax variants can share the same state type.
136/// The bounds preserve the public session's existing thread and unwind-safety guarantees.
137pub(crate) trait AdapterSessionState:
138    Any + Send + Sync + UnwindSafe + RefUnwindSafe
139{
140    fn initialize(
141        root: Option<&Path>,
142        registrations: &[String],
143        assertion_modules: &[String],
144    ) -> Self;
145}
146
147/// Internal stateful registrations must explicitly select their backend state.
148pub(crate) trait StatefulSourceAdapter: SourceAdapter {
149    type State: AdapterSessionState;
150}
151
152impl SourceExtractionSession {
153    /// Creates an extraction session whose imported modules must remain inside `root`.
154    pub fn new(root: &Path) -> Self {
155        Self::with_registrations(root, &[])
156    }
157
158    /// Creates a session that also treats `registrations` as step-registration function names.
159    ///
160    /// Use this for project-declared wrappers that static inference cannot recognize, such as a
161    /// helper that builds its registration call dynamically.
162    pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
163        Self::with_options(root, registrations, &[])
164    }
165
166    /// Creates a session that also trusts `assertion_modules` as assertion-factory providers.
167    ///
168    /// Use this for a local module that re-exports `expect` from a supported assertion package, or
169    /// for a runner this build does not recognize by name. Without it such a factory is untrusted
170    /// and its expected values are not treated as behaviour.
171    pub fn with_options(
172        root: &Path,
173        registrations: &[String],
174        assertion_modules: &[String],
175    ) -> Self {
176        let mut session = Self {
177            root: Some(root.to_owned()),
178            registrations: registrations.to_vec(),
179            assertion_modules: assertion_modules.to_vec(),
180            ..Self::default()
181        };
182        // Preserve construction-time root resolution, including a failed canonicalization.
183        for registration in SOURCE_ADAPTER_REGISTRY {
184            if let Some(initialize) = registration.initialize_session {
185                initialize(&mut session);
186            }
187        }
188        session
189    }
190
191    pub(crate) fn initialize<T: AdapterSessionState>(&mut self) {
192        self.states.entry(TypeId::of::<T>()).or_insert_with(|| {
193            Box::new(T::initialize(
194                self.root.as_deref(),
195                &self.registrations,
196                &self.assertion_modules,
197            ))
198        });
199    }
200
201    pub(crate) fn state<T: AdapterSessionState>(&mut self) -> Result<&mut T> {
202        // Explicit-root sessions must never establish a boundary later than construction.
203        if self.root.is_none() {
204            self.initialize::<T>();
205        }
206        self.states
207            .get_mut(&TypeId::of::<T>())
208            .and_then(|state| {
209                let state: &mut dyn Any = state.as_mut();
210                state.downcast_mut::<T>()
211            })
212            .with_context(|| {
213                format!(
214                    "source adapter state {} is uninitialized or incompatible",
215                    std::any::type_name::<T>()
216                )
217            })
218    }
219}
220
221/// Converts one supported definition-source language into the shared definition IR.
222pub trait SourceAdapter: Sync {
223    /// Stable adapter name used in diagnostics and registry inspection.
224    fn name(&self) -> &'static str;
225
226    /// Parser language produced by this adapter registration.
227    fn language(&self) -> SourceLanguage;
228
229    /// Extracts definitions from in-memory source using `file` for source locations.
230    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
231
232    /// Extracts definitions while sharing bounded state with other files in the same run.
233    fn extract_with_session(
234        &self,
235        source: &str,
236        file: &SourceFile,
237        _session: &mut SourceExtractionSession,
238    ) -> Result<Extraction> {
239        self.extract(source, file)
240    }
241
242    /// Reads and extracts one source file.
243    fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
244        let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
245        self.extract(&source, file)
246    }
247
248    /// Reads and extracts one source file using shared run state.
249    fn extract_file_with_session(
250        &self,
251        file: &SourceFile,
252        session: &mut SourceExtractionSession,
253    ) -> Result<Extraction> {
254        let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
255        self.extract_with_session(&source, file, session)
256    }
257}
258
259/// One suffix-to-adapter registration.
260#[non_exhaustive]
261#[derive(Clone, Copy)]
262pub struct SourceAdapterRegistration {
263    /// Filename suffix, including the leading dot.
264    pub suffix: &'static str,
265    /// Adapter responsible for matching sources.
266    pub adapter: &'static dyn SourceAdapter,
267    initialize_session: Option<fn(&mut SourceExtractionSession)>,
268}
269
270impl SourceAdapterRegistration {
271    /// Creates one suffix-to-adapter registration.
272    pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
273        Self {
274            suffix,
275            adapter,
276            initialize_session: None,
277        }
278    }
279
280    const fn with_session<A: StatefulSourceAdapter>(
281        suffix: &'static str,
282        adapter: &'static A,
283    ) -> Self {
284        Self {
285            suffix,
286            adapter,
287            initialize_session: Some(SourceExtractionSession::initialize::<A::State>),
288        }
289    }
290}
291
292/// The authoritative language/adapter mapping: one table drives both the suffix registry and the
293/// language lookup, so the two routing surfaces cannot drift apart. Adding a language means one
294/// row here; the expansion stays an exhaustive match, so routing remains total without a
295/// fallible lookup.
296macro_rules! register_source_adapters {
297    ( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
298        /// Registered definition-source suffixes in deterministic lookup order.
299        pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
300            $(
301                $(
302                    SourceAdapterRegistration::with_session($suffix, &$adapter),
303                )+
304            )+
305        ];
306
307        /// Returns the adapter for an already classified source language.
308        pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
309            match language {
310                $(SourceLanguage::$language => &$adapter,)+
311            }
312        }
313    };
314}
315
316register_source_adapters! {
317    JavaScript => crate::typescript::JAVASCRIPT_ADAPTER : ".mjs", ".cjs", ".jsx", ".js";
318    TypeScript => crate::typescript::TYPESCRIPT_ADAPTER : ".mts", ".cts", ".ts";
319    Tsx => crate::typescript::TSX_ADAPTER : ".tsx";
320}
321
322/// Returns the adapter registered for `path`, rejecting declaration and source-map files.
323pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
324    let extension = path.extension()?.to_str()?;
325    if extension == "map" || is_declaration_file(path, extension) {
326        return None;
327    }
328    SOURCE_ADAPTER_REGISTRY
329        .iter()
330        .find(|registration| extension == registration.suffix.trim_start_matches('.'))
331        .map(|registration| registration.adapter)
332}
333
334fn is_declaration_file(path: &Path, extension: &str) -> bool {
335    matches!(extension, "ts" | "mts" | "cts")
336        && path.file_stem().is_some_and(|stem| {
337            stem == ".d"
338                || Path::new(stem)
339                    .extension()
340                    .is_some_and(|extension| extension == "d")
341        })
342}
343
344/// Classifies a path using the registered suffixes.
345pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
346    adapter_for_path(path).map(SourceAdapter::language)
347}
348
349/// Returns the registered suffixes for tooling and adapter conformance tests.
350pub fn registered_suffixes() -> Vec<&'static str> {
351    SOURCE_ADAPTER_REGISTRY
352        .iter()
353        .map(|registration| registration.suffix)
354        .collect()
355}
356
357#[cfg(test)]
358mod tests;