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}