Skip to main content

equilibrium_ffi/
exports.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5
6use crate::detector::Language;
7use crate::limits::{read_config_text, read_discovery_source};
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum ExportSource {
11    Requested,
12    Config,
13    ExplicitMarkers,
14    AllFunctions,
15}
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct ExportDiscovery {
19    pub exports: Vec<String>,
20    pub source: ExportSource,
21    pub warnings: Vec<String>,
22}
23
24#[derive(Clone, Debug, Default)]
25pub struct ExportOptions {
26    pub exports: Vec<String>,
27    pub config_path: Option<PathBuf>,
28}
29
30impl ExportOptions {
31    pub fn exports<I, S>(mut self, exports: I) -> Self
32    where
33        I: IntoIterator<Item = S>,
34        S: Into<String>,
35    {
36        self.exports = exports.into_iter().map(Into::into).collect();
37        self
38    }
39
40    pub fn config_path<P: AsRef<Path>>(mut self, path: P) -> Self {
41        self.config_path = Some(path.as_ref().to_path_buf());
42        self
43    }
44}
45
46#[derive(Debug)]
47pub enum ExportError {
48    Io {
49        path: PathBuf,
50        error: std::io::Error,
51    },
52    Config {
53        path: PathBuf,
54        message: String,
55    },
56}
57
58impl std::fmt::Display for ExportError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            ExportError::Io { path, error } => {
62                write!(
63                    f,
64                    "failed to read exports from {}: {}",
65                    path.display(),
66                    error
67                )
68            }
69            ExportError::Config { path, message } => {
70                write!(f, "failed to read config {}: {}", path.display(), message)
71            }
72        }
73    }
74}
75
76impl std::error::Error for ExportError {}
77
78#[derive(Deserialize)]
79struct EquilibriumConfig {
80    target: Option<BTreeMap<String, TargetConfig>>,
81}
82
83#[derive(Deserialize)]
84struct TargetConfig {
85    language: Option<String>,
86    sources: Option<Vec<String>>,
87    exports: Option<Vec<String>>,
88}
89
90#[derive(Clone)]
91struct FunctionCandidate {
92    name: String,
93    signature: String,
94    explicit: bool,
95}
96
97pub fn discover_exports_with_options(
98    path: &Path,
99    language: Language,
100    options: &ExportOptions,
101) -> Result<ExportDiscovery, ExportError> {
102    if !options.exports.is_empty() {
103        return Ok(ExportDiscovery {
104            exports: dedupe(options.exports.clone()),
105            source: ExportSource::Requested,
106            warnings: Vec::new(),
107        });
108    }
109
110    if let Some(exports) = config_exports(path, language, options)? {
111        return Ok(ExportDiscovery {
112            exports,
113            source: ExportSource::Config,
114            warnings: Vec::new(),
115        });
116    }
117
118    let content = read_discovery_source(path).map_err(|message| ExportError::Config {
119        path: path.to_path_buf(),
120        message,
121    })?;
122    let candidates = language_candidates(language, &content);
123    let explicit: Vec<FunctionCandidate> =
124        candidates.iter().filter(|c| c.explicit).cloned().collect();
125    if !explicit.is_empty() {
126        let (exports, warnings) = supported_exports(explicit, language);
127        return Ok(ExportDiscovery {
128            exports,
129            source: ExportSource::ExplicitMarkers,
130            warnings,
131        });
132    }
133
134    let (exports, warnings) = supported_exports(candidates, language);
135    Ok(ExportDiscovery {
136        exports,
137        source: ExportSource::AllFunctions,
138        warnings,
139    })
140}
141
142fn config_exports(
143    source: &Path,
144    language: Language,
145    options: &ExportOptions,
146) -> Result<Option<Vec<String>>, ExportError> {
147    for config_path in config_candidates(source, options) {
148        if !config_path.is_file() {
149            continue;
150        }
151        let config_text =
152            read_config_text(&config_path).map_err(|message| ExportError::Config {
153                path: config_path.clone(),
154                message,
155            })?;
156        let config: EquilibriumConfig =
157            toml::from_str(&config_text).map_err(|error| ExportError::Config {
158                path: config_path.clone(),
159                message: error.to_string(),
160            })?;
161        let Some(targets) = config.target else {
162            continue;
163        };
164        let base = config_path.parent().unwrap_or(Path::new("."));
165        for target in targets.values() {
166            if target_matches(target, source, base, language) {
167                if let Some(exports) = &target.exports {
168                    return Ok(Some(dedupe(exports.clone())));
169                }
170            }
171        }
172    }
173    Ok(None)
174}
175
176fn config_candidates(source: &Path, options: &ExportOptions) -> Vec<PathBuf> {
177    if let Some(path) = &options.config_path {
178        return vec![path.clone()];
179    }
180    let mut candidates = Vec::new();
181    if let Some(parent) = source.parent() {
182        candidates.push(parent.join("equilibrium.toml"));
183    }
184    if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
185        candidates.push(PathBuf::from(manifest_dir).join("equilibrium.toml"));
186    }
187    candidates.dedup();
188    candidates
189}
190
191fn target_matches(target: &TargetConfig, source: &Path, base: &Path, language: Language) -> bool {
192    if let Some(target_language) = &target.language {
193        if target_language.to_ascii_lowercase() != language.cli_name() {
194            return false;
195        }
196    }
197    let Some(sources) = &target.sources else {
198        return false;
199    };
200    let canonical_source = source
201        .canonicalize()
202        .unwrap_or_else(|_| source.to_path_buf());
203    sources.iter().any(|candidate| {
204        let candidate_path = base.join(candidate);
205        let canonical_candidate = candidate_path
206            .canonicalize()
207            .unwrap_or_else(|_| candidate_path.clone());
208        canonical_candidate == canonical_source || candidate_path == source
209    })
210}
211
212fn language_candidates(language: Language, content: &str) -> Vec<FunctionCandidate> {
213    match language {
214        Language::Rust => rust_candidates(content),
215        Language::Zig => zig_candidates(content),
216        Language::Nim => nim_candidates(content),
217        Language::D => d_candidates(content),
218        Language::C | Language::Cpp => c_candidates(content),
219        _ => Vec::new(),
220    }
221}
222
223fn rust_candidates(content: &str) -> Vec<FunctionCandidate> {
224    let mut out = Vec::new();
225    let mut ffi_pending = false;
226    for line in content.lines() {
227        let trimmed = line.trim();
228        if trimmed.starts_with("#[") && trimmed.contains("ffi") {
229            ffi_pending = true;
230            continue;
231        }
232        if let Some(signature) = rust_signature(trimmed) {
233            if let Some(name) = name_after_keyword(signature, "fn") {
234                out.push(FunctionCandidate {
235                    name,
236                    signature: signature.to_string(),
237                    explicit: ffi_pending,
238                });
239            }
240            ffi_pending = false;
241        } else if !trimmed.starts_with("#[") && !trimmed.is_empty() {
242            ffi_pending = false;
243        }
244    }
245    out
246}
247
248fn rust_signature(line: &str) -> Option<&str> {
249    if line.starts_with("pub fn ") || line.starts_with("fn ") {
250        return Some(line);
251    }
252    None
253}
254
255fn zig_candidates(content: &str) -> Vec<FunctionCandidate> {
256    content
257        .lines()
258        .filter_map(|line| {
259            let trimmed = line.trim();
260            let explicit = trimmed.starts_with("pub export fn ");
261            if explicit || trimmed.starts_with("pub fn ") || trimmed.starts_with("fn ") {
262                name_after_keyword(trimmed, "fn").map(|name| FunctionCandidate {
263                    name,
264                    signature: trimmed.to_string(),
265                    explicit,
266                })
267            } else {
268                None
269            }
270        })
271        .collect()
272}
273
274fn nim_candidates(content: &str) -> Vec<FunctionCandidate> {
275    content
276        .lines()
277        .filter_map(|line| {
278            let trimmed = line.trim();
279            if !trimmed.starts_with("proc ") {
280                return None;
281            }
282            let rest = trimmed.trim_start_matches("proc ").trim_start();
283            let name_end = rest
284                .find(|c: char| c == '(' || c == '*' || c.is_whitespace())
285                .unwrap_or(rest.len());
286            let name = rest[..name_end].to_string();
287            if name.is_empty() {
288                return None;
289            }
290            Some(FunctionCandidate {
291                name,
292                signature: trimmed.to_string(),
293                explicit: trimmed.contains("exportc") || rest.contains('*'),
294            })
295        })
296        .collect()
297}
298
299fn d_candidates(content: &str) -> Vec<FunctionCandidate> {
300    content
301        .lines()
302        .filter_map(|line| {
303            let trimmed = line.trim();
304            if !trimmed.contains('(') || trimmed.starts_with('@') {
305                return None;
306            }
307            let explicit = trimmed.contains("extern(C)") && trimmed.contains("export");
308            d_name(trimmed).map(|name| FunctionCandidate {
309                name,
310                signature: trimmed.to_string(),
311                explicit,
312            })
313        })
314        .collect()
315}
316
317fn c_candidates(content: &str) -> Vec<FunctionCandidate> {
318    content
319        .lines()
320        .filter_map(|line| {
321            let trimmed = line.trim();
322            if trimmed.starts_with("static ")
323                || trimmed.starts_with('#')
324                || !trimmed.contains('(')
325                || !trimmed.contains(')')
326            {
327                return None;
328            }
329            let explicit = trimmed.ends_with(';');
330            if !explicit && !trimmed.ends_with('{') {
331                return None;
332            }
333            c_name(trimmed).map(|name| FunctionCandidate {
334                name,
335                signature: trimmed.to_string(),
336                explicit,
337            })
338        })
339        .collect()
340}
341
342fn name_after_keyword(line: &str, keyword: &str) -> Option<String> {
343    let start = line.find(keyword)?;
344    let rest = line[start + keyword.len()..].trim_start();
345    let end = rest.find(|c: char| c == '(' || c.is_whitespace())?;
346    let name = &rest[..end];
347    if name.is_empty() {
348        None
349    } else {
350        Some(name.to_string())
351    }
352}
353
354fn d_name(line: &str) -> Option<String> {
355    let before_paren = line.rsplit_once('(')?.0.trim();
356    let name = before_paren.split_whitespace().last()?;
357    if matches!(name, "if" | "for" | "while" | "switch") {
358        None
359    } else {
360        Some(name.to_string())
361    }
362}
363
364fn c_name(line: &str) -> Option<String> {
365    let before_paren = line.rsplit_once('(')?.0.trim();
366    let name = before_paren.split_whitespace().last()?;
367    if matches!(name, "if" | "for" | "while" | "switch") {
368        None
369    } else {
370        Some(name.trim_start_matches('*').to_string())
371    }
372}
373
374fn supported_exports(
375    candidates: Vec<FunctionCandidate>,
376    language: Language,
377) -> (Vec<String>, Vec<String>) {
378    let mut exports = Vec::new();
379    let mut warnings = Vec::new();
380    for candidate in candidates {
381        if signature_supported(&candidate.signature, language) {
382            exports.push(candidate.name);
383        } else {
384            warnings.push(format!(
385                "skipped export {} because its signature is not C ABI safe",
386                candidate.name
387            ));
388        }
389    }
390    (dedupe(exports), warnings)
391}
392
393fn signature_supported(signature: &str, language: Language) -> bool {
394    match language {
395        Language::Rust => rust_signature_supported(signature),
396        _ => true,
397    }
398}
399
400fn rust_signature_supported(signature: &str) -> bool {
401    let Some(params_start) = signature.find('(') else {
402        return false;
403    };
404    let Some(params_end) = signature[params_start..].find(')') else {
405        return false;
406    };
407    let params = &signature[params_start + 1..params_start + params_end];
408    for param in params.split(',').map(str::trim).filter(|p| !p.is_empty()) {
409        let Some((_, ty)) = param.rsplit_once(':') else {
410            return false;
411        };
412        if !rust_type_supported(ty.trim()) {
413            return false;
414        }
415    }
416    if let Some((_, return_type)) = signature.split_once("->") {
417        let ty = return_type
418            .split('{')
419            .next()
420            .unwrap_or(return_type)
421            .trim()
422            .trim_end_matches(';');
423        rust_type_supported(ty)
424    } else {
425        true
426    }
427}
428
429fn rust_type_supported(ty: &str) -> bool {
430    if ty.starts_with("*const ") || ty.starts_with("*mut ") {
431        return true;
432    }
433    matches!(
434        ty,
435        "()" | "bool"
436            | "i8"
437            | "u8"
438            | "i16"
439            | "u16"
440            | "i32"
441            | "u32"
442            | "i64"
443            | "u64"
444            | "isize"
445            | "usize"
446            | "f32"
447            | "f64"
448            | "c_int"
449            | "c_uint"
450            | "c_char"
451            | "c_void"
452    )
453}
454
455fn dedupe(values: Vec<String>) -> Vec<String> {
456    let mut out = Vec::new();
457    for value in values {
458        if !out.contains(&value) {
459            out.push(value);
460        }
461    }
462    out
463}