Skip to main content

normalize_languages/
ffi.rs

1//! Cross-language FFI binding detection.
2//!
3//! This module provides trait-based FFI detection for identifying
4//! cross-language bindings like PyO3, wasm-bindgen, napi-rs, ctypes, etc.
5
6use std::path::Path;
7
8/// A detected FFI module/crate.
9#[derive(Debug, Clone)]
10pub struct FfiModule {
11    /// Name of the module/crate (e.g., "my-pyo3-lib")
12    pub name: String,
13    /// Path to the main source file (e.g., "src/lib.rs")
14    pub lib_path: String,
15    /// The binding type that detected this module
16    pub binding_type: &'static str,
17    /// Source language
18    pub source_lang: &'static str,
19    /// Target language
20    pub target_lang: &'static str,
21}
22
23/// A cross-language reference found in source code.
24#[derive(Debug, Clone)]
25pub struct CrossRef {
26    /// File containing the reference
27    pub source_file: String,
28    /// Language of the source file
29    pub source_lang: &'static str,
30    /// Target module/crate being referenced
31    pub target_module: String,
32    /// Language of the target
33    pub target_lang: &'static str,
34    /// Type of reference (e.g., "pyo3_import", "ctypes_usage")
35    pub ref_type: &'static str,
36    /// Line number of the reference
37    pub line: usize,
38}
39
40/// Trait for FFI binding detection.
41///
42/// Each binding type (PyO3, wasm-bindgen, etc.) implements this trait
43/// to describe how to detect it in a project.
44pub trait FfiBinding: Send + Sync {
45    /// Unique identifier for this binding type (e.g., "pyo3", "wasm-bindgen")
46    fn name(&self) -> &'static str;
47
48    /// Source language for this binding (e.g., "rust")
49    fn source_lang(&self) -> &'static str;
50
51    /// Target language for this binding (e.g., "python")
52    fn target_lang(&self) -> &'static str;
53
54    /// Check if a build file (e.g., Cargo.toml) indicates this binding is used.
55    /// Returns the module name if detected.
56    fn detect_in_build_file(&self, path: &Path, content: &str) -> Option<String>;
57
58    /// File extensions that may contain imports of this binding's modules.
59    fn consumer_extensions(&self) -> &[&'static str];
60
61    /// Check if an import line references a module from this binding.
62    /// `module_name` is the crate/package name (with underscores for Rust).
63    fn matches_import(&self, import_module: &str, import_name: &str, known_module: &str) -> bool;
64}
65
66// ============================================================================
67// Built-in FFI Binding Implementations
68// ============================================================================
69
70/// PyO3 - Rust to Python bindings
71pub struct PyO3Binding;
72
73impl FfiBinding for PyO3Binding {
74    fn name(&self) -> &'static str {
75        "pyo3"
76    }
77
78    fn source_lang(&self) -> &'static str {
79        "rust"
80    }
81
82    fn target_lang(&self) -> &'static str {
83        "python"
84    }
85
86    fn detect_in_build_file(&self, path: &Path, content: &str) -> Option<String> {
87        if path.file_name()? != "Cargo.toml" {
88            return None;
89        }
90        if !content.contains("pyo3") && !content.contains("PyO3") {
91            return None;
92        }
93        extract_cargo_crate_name(content)
94    }
95
96    fn consumer_extensions(&self) -> &[&'static str] {
97        &["py"]
98    }
99
100    fn matches_import(&self, import_module: &str, import_name: &str, known_module: &str) -> bool {
101        // PyO3 modules use underscores instead of hyphens
102        let module_name = known_module.replace('-', "_");
103        import_module == module_name
104            || import_module.starts_with(&format!("{}.", module_name))
105            || import_name == module_name
106    }
107}
108
109/// wasm-bindgen - Rust to WebAssembly/JavaScript
110pub struct WasmBindgenBinding;
111
112impl FfiBinding for WasmBindgenBinding {
113    fn name(&self) -> &'static str {
114        "wasm-bindgen"
115    }
116
117    fn source_lang(&self) -> &'static str {
118        "rust"
119    }
120
121    fn target_lang(&self) -> &'static str {
122        "javascript"
123    }
124
125    fn detect_in_build_file(&self, path: &Path, content: &str) -> Option<String> {
126        if path.file_name()? != "Cargo.toml" {
127            return None;
128        }
129        if !content.contains("wasm-bindgen") {
130            return None;
131        }
132        extract_cargo_crate_name(content)
133    }
134
135    fn consumer_extensions(&self) -> &[&'static str] {
136        &["js", "ts", "tsx", "mjs"]
137    }
138
139    fn matches_import(&self, import_module: &str, import_name: &str, known_module: &str) -> bool {
140        let module_name = known_module.replace('-', "_");
141        import_module == module_name
142            || import_module.starts_with(&format!("{}.", module_name))
143            || import_name == module_name
144            || import_module.contains(&format!("/{}", module_name))
145    }
146}
147
148/// napi-rs - Rust to Node.js native modules
149pub struct NapiRsBinding;
150
151impl FfiBinding for NapiRsBinding {
152    fn name(&self) -> &'static str {
153        "napi-rs"
154    }
155
156    fn source_lang(&self) -> &'static str {
157        "rust"
158    }
159
160    fn target_lang(&self) -> &'static str {
161        "javascript"
162    }
163
164    fn detect_in_build_file(&self, path: &Path, content: &str) -> Option<String> {
165        if path.file_name()? != "Cargo.toml" {
166            return None;
167        }
168        if !content.contains("napi") {
169            return None;
170        }
171        extract_cargo_crate_name(content)
172    }
173
174    fn consumer_extensions(&self) -> &[&'static str] {
175        &["js", "ts", "tsx", "mjs"]
176    }
177
178    fn matches_import(&self, import_module: &str, import_name: &str, known_module: &str) -> bool {
179        let module_name = known_module.replace('-', "_");
180        import_module == module_name
181            || import_module.starts_with(&format!("{}.", module_name))
182            || import_name == module_name
183    }
184}
185
186/// Generic cdylib - Rust C ABI exports
187pub struct CdylibBinding;
188
189impl FfiBinding for CdylibBinding {
190    fn name(&self) -> &'static str {
191        "cdylib"
192    }
193
194    fn source_lang(&self) -> &'static str {
195        "rust"
196    }
197
198    fn target_lang(&self) -> &'static str {
199        "c"
200    }
201
202    fn detect_in_build_file(&self, path: &Path, content: &str) -> Option<String> {
203        if path.file_name()? != "Cargo.toml" {
204            return None;
205        }
206        // Only match cdylib that isn't already caught by pyo3/wasm-bindgen/napi
207        if !content.contains("cdylib") {
208            return None;
209        }
210        if content.contains("pyo3") || content.contains("wasm-bindgen") || content.contains("napi")
211        {
212            return None;
213        }
214        extract_cargo_crate_name(content)
215    }
216
217    fn consumer_extensions(&self) -> &[&'static str] {
218        &["c", "cpp", "h", "hpp", "py"] // Could be called from many languages
219    }
220
221    fn matches_import(
222        &self,
223        _import_module: &str,
224        _import_name: &str,
225        _known_module: &str,
226    ) -> bool {
227        // Generic cdylib matching is harder - would need to look for dlopen/LoadLibrary calls
228        false
229    }
230}
231
232/// Python ctypes - Python calling C libraries
233pub struct CtypesBinding;
234
235impl FfiBinding for CtypesBinding {
236    fn name(&self) -> &'static str {
237        "ctypes"
238    }
239
240    fn source_lang(&self) -> &'static str {
241        "python"
242    }
243
244    fn target_lang(&self) -> &'static str {
245        "c"
246    }
247
248    fn detect_in_build_file(&self, _path: &Path, _content: &str) -> Option<String> {
249        // ctypes doesn't have a build file indicator
250        None
251    }
252
253    fn consumer_extensions(&self) -> &[&'static str] {
254        &["py"]
255    }
256
257    fn matches_import(&self, import_module: &str, import_name: &str, _known_module: &str) -> bool {
258        import_module == "ctypes" || import_name == "ctypes" || import_name == "CDLL"
259    }
260}
261
262/// Python cffi - Python calling C libraries
263pub struct CffiBinding;
264
265impl FfiBinding for CffiBinding {
266    fn name(&self) -> &'static str {
267        "cffi"
268    }
269
270    fn source_lang(&self) -> &'static str {
271        "python"
272    }
273
274    fn target_lang(&self) -> &'static str {
275        "c"
276    }
277
278    fn detect_in_build_file(&self, _path: &Path, _content: &str) -> Option<String> {
279        None
280    }
281
282    fn consumer_extensions(&self) -> &[&'static str] {
283        &["py"]
284    }
285
286    fn matches_import(&self, import_module: &str, import_name: &str, _known_module: &str) -> bool {
287        import_module == "cffi" || import_name == "cffi" || import_name == "FFI"
288    }
289}
290
291// ============================================================================
292// FFI Detector Registry
293// ============================================================================
294
295/// Registry of all FFI binding detectors.
296pub struct FfiDetector {
297    bindings: Vec<Box<dyn FfiBinding>>,
298}
299
300impl Default for FfiDetector {
301    fn default() -> Self {
302        Self::new()
303    }
304}
305
306impl FfiDetector {
307    /// Create a new detector with all built-in bindings.
308    pub fn new() -> Self {
309        Self {
310            bindings: vec![
311                Box::new(PyO3Binding),
312                Box::new(WasmBindgenBinding),
313                Box::new(NapiRsBinding),
314                Box::new(CdylibBinding),
315                Box::new(CtypesBinding),
316                Box::new(CffiBinding),
317            ],
318        }
319    }
320
321    /// Add a custom binding detector.
322    pub fn add_binding(&mut self, binding: Box<dyn FfiBinding>) {
323        self.bindings.push(binding);
324    }
325
326    /// Get all registered bindings.
327    pub fn bindings(&self) -> &[Box<dyn FfiBinding>] {
328        &self.bindings
329    }
330
331    /// Detect FFI modules from a build file.
332    pub fn detect_modules(&self, path: &Path, content: &str) -> Vec<FfiModule> {
333        let mut modules = Vec::new();
334        let parent = path.parent().unwrap_or(Path::new(""));
335        let lib_path = parent.join("src").join("lib.rs");
336
337        for binding in &self.bindings {
338            if let Some(name) = binding.detect_in_build_file(path, content) {
339                modules.push(FfiModule {
340                    name,
341                    lib_path: lib_path.to_string_lossy().to_string(),
342                    binding_type: binding.name(),
343                    source_lang: binding.source_lang(),
344                    target_lang: binding.target_lang(),
345                });
346            }
347        }
348
349        modules
350    }
351
352    /// Check if an import matches any known FFI module.
353    pub fn match_import<'a>(
354        &self,
355        import_module: &str,
356        import_name: &str,
357        known_modules: &'a [FfiModule],
358    ) -> Option<(&'a FfiModule, &'static str)> {
359        for module in known_modules {
360            for binding in &self.bindings {
361                if binding.name() == module.binding_type
362                    && binding.matches_import(import_module, import_name, &module.name)
363                {
364                    return Some((module, binding.name()));
365                }
366            }
367        }
368        None
369    }
370
371    /// Check if a file extension can consume FFI modules.
372    pub fn is_consumer_extension(&self, ext: &str) -> bool {
373        for binding in &self.bindings {
374            if binding.consumer_extensions().contains(&ext) {
375                return true;
376            }
377        }
378        false
379    }
380}
381
382// ============================================================================
383// Helpers
384// ============================================================================
385
386/// Extract crate name from Cargo.toml content.
387fn extract_cargo_crate_name(content: &str) -> Option<String> {
388    let mut in_package = false;
389    for line in content.lines() {
390        let trimmed = line.trim();
391        if trimmed == "[package]" {
392            in_package = true;
393            continue;
394        }
395        if trimmed.starts_with('[') {
396            in_package = false;
397            continue;
398        }
399        if in_package
400            && trimmed.starts_with("name")
401            && let Some(eq_pos) = trimmed.find('=')
402        {
403            let value = trimmed[eq_pos + 1..].trim();
404            let value = value.trim_matches('"').trim_matches('\'');
405            return Some(value.to_string());
406        }
407    }
408    None
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn test_pyo3_detection() {
417        let binding = PyO3Binding;
418        let content = r#"
419[package]
420name = "my-lib"
421version = "0.1.0"
422
423[dependencies]
424pyo3 = "0.20"
425"#;
426        let result = binding.detect_in_build_file(Path::new("Cargo.toml"), content);
427        assert_eq!(result, Some("my-lib".to_string()));
428    }
429
430    #[test]
431    fn test_pyo3_import_matching() {
432        let binding = PyO3Binding;
433        assert!(binding.matches_import("my_lib", "", "my-lib"));
434        assert!(binding.matches_import("my_lib.submodule", "", "my-lib"));
435        assert!(!binding.matches_import("other_lib", "", "my-lib"));
436    }
437
438    #[test]
439    fn test_detector_registry() {
440        let detector = FfiDetector::new();
441        assert!(detector.bindings().len() >= 6);
442
443        let content = r#"
444[package]
445name = "wasm-app"
446
447[dependencies]
448wasm-bindgen = "0.2"
449"#;
450        let modules = detector.detect_modules(Path::new("Cargo.toml"), content);
451        assert_eq!(modules.len(), 1);
452        assert_eq!(modules[0].name, "wasm-app");
453        assert_eq!(modules[0].binding_type, "wasm-bindgen");
454    }
455}