Skip to main content

equilibrium_ffi/
loader.rs

1//! One-liner FFI loading — compile, generate bindings, link.
2
3use std::path::{Path, PathBuf};
4
5use crate::bindings::{generate_bindings_from_content, BindingOptions, GeneratedBinding};
6use crate::c_header::parse_c_header;
7use crate::compiler::{
8    compile_to_c_with_extra, extra_link_arg_allowed, validate_extra_args, CompileResult,
9};
10use crate::detector::{detect_language, find_compiler, Language};
11use crate::exports::{discover_exports_with_options, ExportOptions, ExportSource};
12use crate::imports::{generate_imports, GeneratedImport, ImportOptions};
13use crate::limits::read_header_content;
14
15/// Options for loading a foreign module.
16#[derive(Clone, Debug)]
17pub struct LoadOptions {
18    /// Generate Rust bindings from the header (default: true)
19    pub generate_bindings: bool,
20    /// Compile the source (default: true)
21    pub compile: bool,
22    /// Output directory (default: target/native)
23    pub output_dir: Option<PathBuf>,
24    /// Custom binding options
25    pub binding_options: Option<BindingOptions>,
26    /// Link the compiled library (default: true for object files)
27    pub link: bool,
28    /// Extra compile args appended to the compiler invocation
29    pub compile_args: Vec<String>,
30    /// Reserved for a future link step (stored on options, not applied yet)
31    pub link_args: Vec<String>,
32    pub exports: Vec<String>,
33    pub config_path: Option<PathBuf>,
34    pub consumer_languages: Vec<Language>,
35}
36
37impl Default for LoadOptions {
38    fn default() -> Self {
39        Self {
40            generate_bindings: true,
41            compile: true,
42            output_dir: None,
43            binding_options: None,
44            link: true,
45            link_args: Vec::new(),
46            compile_args: Vec::new(),
47            exports: Vec::new(),
48            config_path: None,
49            consumer_languages: Vec::new(),
50        }
51    }
52}
53
54impl LoadOptions {
55    pub fn exports<I, S>(mut self, exports: I) -> Self
56    where
57        I: IntoIterator<Item = S>,
58        S: Into<String>,
59    {
60        self.exports = exports.into_iter().map(Into::into).collect();
61        self
62    }
63
64    pub fn output_dir<P: AsRef<Path>>(mut self, output_dir: P) -> Self {
65        self.output_dir = Some(output_dir.as_ref().to_path_buf());
66        self
67    }
68
69    pub fn generate_bindings(mut self, generate_bindings: bool) -> Self {
70        self.generate_bindings = generate_bindings;
71        self
72    }
73
74    pub fn compile(mut self, compile: bool) -> Self {
75        self.compile = compile;
76        self
77    }
78
79    pub fn link(mut self, link: bool) -> Self {
80        self.link = link;
81        self
82    }
83
84    pub fn compile_args<I, S>(mut self, args: I) -> Self
85    where
86        I: IntoIterator<Item = S>,
87        S: Into<String>,
88    {
89        self.compile_args = args.into_iter().map(Into::into).collect();
90        self
91    }
92
93    pub fn config_path<P: AsRef<Path>>(mut self, path: P) -> Self {
94        self.config_path = Some(path.as_ref().to_path_buf());
95        self
96    }
97
98    pub fn consumer_languages<I>(mut self, languages: I) -> Self
99    where
100        I: IntoIterator<Item = Language>,
101    {
102        self.consumer_languages = languages.into_iter().collect();
103        self
104    }
105}
106
107/// Result of loading a foreign module.
108#[derive(Clone, Debug)]
109pub struct LoadedModule {
110    /// Path to the compiled output (C file or object)
111    pub output_path: PathBuf,
112    /// Path to the generated header (if any)
113    pub header_path: Option<PathBuf>,
114    /// The generated Rust bindings
115    pub bindings: Option<GeneratedBinding>,
116    /// The language that was loaded
117    pub language: Language,
118    /// Path to the original source
119    pub source_path: PathBuf,
120    pub exports: Vec<String>,
121    pub export_source: ExportSource,
122    pub warnings: Vec<String>,
123    pub imports: Vec<GeneratedImport>,
124}
125
126impl LoadedModule {
127    /// Get the binding code if available.
128    pub fn bindings_code(&self) -> Option<&str> {
129        self.bindings.as_ref().map(|b| b.code.as_str())
130    }
131}
132
133/// Load a foreign source file — compiles, generates bindings, returns ready-to-use module.
134///
135/// # Example
136///
137/// ```ignore
138/// use equilibrium::load;
139///
140/// // Simple one-liner
141/// let lib = load("native/math.c")?;
142///
143/// // Access compiled output and bindings
144/// println!("Compiled: {:?}", lib.output_path);
145/// if let Some(code) = lib.bindings_code() {
146///     println!("Bindings: {}", code);
147/// }
148/// ```
149///
150/// # Arguments
151/// * `source` - Path to the source file (e.g., "native/v/math.v")
152pub fn load<S: AsRef<Path>>(source: S) -> Result<LoadedModule, LoadError> {
153    load_with_options(source, LoadOptions::default())
154}
155
156/// Load with custom options.
157pub fn load_with_options<S: AsRef<Path>>(
158    source: S,
159    options: LoadOptions,
160) -> Result<LoadedModule, LoadError> {
161    let source = source.as_ref();
162    let source = source
163        .canonicalize()
164        .unwrap_or_else(|_| source.to_path_buf());
165
166    let Some(lang) = detect_language(&source) else {
167        return Err(LoadError::UnknownLanguage(source.clone()));
168    };
169
170    let export_options = ExportOptions {
171        exports: options.exports.clone(),
172        config_path: options.config_path.clone(),
173    };
174    let export_discovery = discover_exports_with_options(&source, lang, &export_options)
175        .map_err(|e| LoadError::ExportFailed(e.to_string()))?;
176
177    let output_dir = options.output_dir.clone().unwrap_or_else(|| {
178        // Use CARGO_MANIFEST_DIR if available, otherwise current dir
179        std::env::var("CARGO_MANIFEST_DIR")
180            .map(PathBuf::from)
181            .unwrap_or_else(|_| PathBuf::from("target"))
182            .join("native")
183            .join(format!("{:?}", lang).to_lowercase())
184    });
185
186    std::fs::create_dir_all(&output_dir).map_err(|e| LoadError::Io {
187        path: output_dir.clone(),
188        error: e,
189    })?;
190
191    if options.compile {
192        find_compiler(lang).ok_or(LoadError::CompilerNotFound(lang))?;
193        validate_extra_args(&options.compile_args)
194            .map_err(|e| LoadError::CompilationFailed(lang, e.to_string()))?;
195    }
196
197    let result = if options.compile {
198        compile_to_c_with_extra(
199            &source,
200            &output_dir,
201            &options.compile_args,
202            &options.link_args,
203        )
204        .map_err(|e| LoadError::CompilationFailed(lang, e.to_string()))?
205    } else {
206        CompileResult {
207            output_path: source.clone(),
208            header_path: sibling_header(&source),
209            language: lang,
210            stdout: String::new(),
211            stderr: String::new(),
212        }
213    };
214
215    let import_source = result.header_path.clone().unwrap_or_else(|| source.clone());
216    let header_content = if import_source
217        .extension()
218        .is_some_and(|ext| matches!(ext.to_str(), Some("h" | "hh" | "hpp" | "hxx")))
219        || options.generate_bindings
220        || !options.consumer_languages.is_empty()
221    {
222        read_header_content(&import_source).ok()
223    } else {
224        None
225    };
226
227    let parsed_header = header_content.as_deref().map(parse_c_header);
228
229    let default_binding_opts = BindingOptions::default();
230    let binding_opts = options
231        .binding_options
232        .as_ref()
233        .unwrap_or(&default_binding_opts);
234
235    let bindings = if options.generate_bindings {
236        if let (Some(content), Some(header_path)) = (&header_content, result.header_path.as_ref()) {
237            Some(
238                generate_bindings_from_content(header_path, content, binding_opts)
239                    .map_err(LoadError::BindingFailed)?,
240            )
241        } else {
242            None
243        }
244    } else {
245        None
246    };
247
248    let import_options =
249        ImportOptions::default().allowlist_functions(export_discovery.exports.clone());
250    let mut imports = Vec::new();
251    let mut warnings = export_discovery.warnings;
252    if options.generate_bindings && bindings.is_none() {
253        warnings.push("no header found; bindings not generated".to_string());
254    }
255    if options.link {
256        emit_cargo_link(lang, &result.output_path, &options.link_args, &mut warnings)?;
257    }
258    for language in &options.consumer_languages {
259        let generated = if let Some(parsed) = &parsed_header {
260            crate::imports::generate_imports_from_parsed(
261                &import_source,
262                *language,
263                parsed,
264                &import_options,
265            )
266            .map_err(LoadError::ImportFailed)?
267        } else {
268            generate_imports(&import_source, *language, &import_options)
269                .map_err(LoadError::ImportFailed)?
270        };
271        warnings.extend(generated.warnings.clone());
272        imports.push(generated);
273    }
274
275    Ok(LoadedModule {
276        output_path: result.output_path,
277        header_path: result.header_path,
278        bindings,
279        language: lang,
280        source_path: source,
281        exports: export_discovery.exports,
282        export_source: export_discovery.source,
283        warnings,
284        imports,
285    })
286}
287
288/// Errors that can occur when loading a module.
289#[derive(Debug)]
290pub enum LoadError {
291    UnknownLanguage(PathBuf),
292    CompilerNotFound(Language),
293    CompilationFailed(Language, String),
294    Io {
295        path: PathBuf,
296        error: std::io::Error,
297    },
298    BindingFailed(String),
299    ExportFailed(String),
300    ImportFailed(String),
301}
302
303impl std::fmt::Display for LoadError {
304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305        match self {
306            LoadError::UnknownLanguage(path) => {
307                write!(f, "Unknown language for file: {}", path.display())
308            }
309            LoadError::CompilerNotFound(lang) => {
310                write!(f, "Compiler for {:?} not found", lang)
311            }
312            LoadError::CompilationFailed(lang, msg) => {
313                write!(f, "Compilation of {:?} failed: {}", lang, msg)
314            }
315            LoadError::Io { path, error } => {
316                write!(f, "IO error for {}: {}", path.display(), error)
317            }
318            LoadError::BindingFailed(msg) => {
319                write!(f, "Binding generation failed: {}", msg)
320            }
321            LoadError::ExportFailed(msg) => {
322                write!(f, "Export discovery failed: {}", msg)
323            }
324            LoadError::ImportFailed(msg) => {
325                write!(f, "Import generation failed: {}", msg)
326            }
327        }
328    }
329}
330
331impl std::error::Error for LoadError {}
332
333fn sibling_header(source: &Path) -> Option<PathBuf> {
334    let header = source.with_extension("h");
335    header.is_file().then_some(header)
336}
337
338fn emit_cargo_link(
339    language: Language,
340    output_path: &Path,
341    link_args: &[String],
342    warnings: &mut Vec<String>,
343) -> Result<(), LoadError> {
344    if std::env::var_os("CARGO").is_none() || std::env::var_os("OUT_DIR").is_none() || cfg!(test) {
345        return Ok(());
346    }
347    if !output_path.is_file() {
348        warnings.push(format!(
349            "link requested but output is missing: {}",
350            output_path.display()
351        ));
352        return Ok(());
353    }
354    for arg in link_args {
355        if !extra_link_arg_allowed(arg) {
356            return Err(LoadError::CompilationFailed(
357                language,
358                format!("rejected extra link argument: {arg}"),
359            ));
360        }
361    }
362    let ext = output_path
363        .extension()
364        .and_then(|e| e.to_str())
365        .unwrap_or("");
366    match ext {
367        "o" | "obj" => {
368            println!("cargo:rustc-link-arg={}", output_path.display());
369        }
370        "a" | "lib" => {
371            if let (Some(dir), Some(stem)) = (output_path.parent(), output_path.file_stem()) {
372                let stem = stem.to_string_lossy();
373                let libname = stem.strip_prefix("lib").unwrap_or(&stem);
374                println!("cargo:rustc-link-search=native={}", dir.display());
375                println!("cargo:rustc-link-lib=static={libname}");
376            }
377        }
378        "so" | "dylib" | "dll" => {
379            if let (Some(dir), Some(stem)) = (output_path.parent(), output_path.file_stem()) {
380                let stem = stem.to_string_lossy();
381                let libname = stem.strip_prefix("lib").unwrap_or(&stem);
382                println!("cargo:rustc-link-search=native={}", dir.display());
383                println!("cargo:rustc-link-lib=dylib={libname}");
384            }
385        }
386        _ => {
387            println!("cargo:rustc-link-arg={}", output_path.display());
388        }
389    }
390    for arg in link_args {
391        println!("cargo:rustc-link-arg={arg}");
392    }
393    Ok(())
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_load_c() {
402        if find_compiler(Language::C).is_none() {
403            return;
404        }
405        let dir = tempfile::tempdir().unwrap();
406        let path = dir.path().join("add.c");
407        let header = dir.path().join("add.h");
408        std::fs::write(&header, "int add(int a, int b);\n").unwrap();
409        std::fs::write(&path, "int add(int a, int b) { return a + b; }\n").unwrap();
410        let out = dir.path().join("out");
411
412        let result = load_with_options(&path, LoadOptions::default().output_dir(&out).link(false))
413            .expect("load C source");
414        assert!(result.output_path.exists());
415        assert_eq!(result.output_path.extension().unwrap(), "o");
416        let code = result.bindings_code().expect("bindings");
417        assert!(code.contains("pub fn add("));
418    }
419
420    #[test]
421    fn test_load_honors_compile_false() {
422        let dir = tempfile::tempdir().unwrap();
423        let path = dir.path().join("add.c");
424        let header = dir.path().join("add.h");
425        std::fs::write(&header, "int add(int a, int b);\n").unwrap();
426        std::fs::write(&path, "int add(int a, int b) { return a + b; }\n").unwrap();
427        let out = dir.path().join("out");
428
429        let result = load_with_options(
430            &path,
431            LoadOptions::default()
432                .output_dir(&out)
433                .compile(false)
434                .link(false),
435        )
436        .expect("load without compile");
437        assert_eq!(
438            result.output_path.canonicalize().unwrap(),
439            path.canonicalize().unwrap()
440        );
441        assert!(result.bindings_code().unwrap().contains("pub fn add("));
442        assert!(!out.join("add.o").exists());
443    }
444
445    #[test]
446    fn test_load_options() {
447        let opts = LoadOptions::default();
448        assert!(opts.generate_bindings);
449        assert!(opts.compile);
450        assert!(opts.link);
451    }
452}