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;