Skip to main content

r/interpreter/native/
compile.rs

1//! Package C/C++ code compilation — Makevars parser and compiler invocation.
2//!
3//! Compiles package `src/*.{c,cpp,cc,cxx}` files into a shared library
4//! (.so/.dylib). Uses the `cc` crate for compiler detection and flag
5//! management (respects CC, CXX, CFLAGS, CXXFLAGS env vars, handles
6//! cross-compilation). Only the final linking step uses `std::process::Command`.
7//!
8//! Reads `src/Makevars` for package-specific flags.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14// region: Anticonf / pkg-config resolution
15
16/// Known mappings from CRAN package names to their pkg-config library names.
17/// Derived from the `PKG_CONFIG_NAME` variable in each package's configure script.
18fn pkg_config_name_for_package(pkg_src_dir: &Path) -> Option<&'static str> {
19    // Try to detect package name from the directory path
20    // The parent of src/ is the package root, whose name is the package
21    let pkg_dir = pkg_src_dir.parent()?;
22    let pkg_name = pkg_dir.file_name()?.to_str()?;
23    match pkg_name {
24        "openssl" => Some("openssl"),
25        "xml2" => Some("libxml-2.0"),
26        "stringi" => Some("icu-i18n"),
27        "curl" => Some("libcurl"),
28        "sodium" => Some("libsodium"),
29        "fs" => Some("libuv"),
30        "cairo" => Some("cairo"),
31        "RPostgres" | "RPostgreSQL" => Some("libpq"),
32        "magick" => Some("Magick++"),
33        "poppler" => Some("poppler-cpp"),
34        "protolite" => Some("protobuf"),
35        "pdftools" => Some("poppler-glib"),
36        "rsvg" => Some("librsvg-2.0"),
37        "gifski" => Some("gifski"),
38        _ => None,
39    }
40}
41
42/// Parse a configure script to extract the pkg-config library name.
43/// Looks for `PKG_CONFIG_NAME="..."` pattern.
44fn extract_pkg_config_name_from_configure(pkg_src_dir: &Path) -> Option<String> {
45    let configure = pkg_src_dir.parent()?.join("configure");
46    let content = std::fs::read_to_string(configure).ok()?;
47    for line in content.lines() {
48        let trimmed = line.trim();
49        if let Some(rest) = trimmed.strip_prefix("PKG_CONFIG_NAME=") {
50            let name = rest.trim_matches('"').trim_matches('\'');
51            if !name.is_empty() {
52                return Some(name.to_string());
53            }
54        }
55    }
56    None
57}
58
59/// Resolve `@cflags@` and `@libs@` placeholders in a Makevars.in file
60/// by querying pkg-config, replicating R's "anticonf" configure pattern.
61fn resolve_anticonf(pkg_src_dir: &Path, makevars_in_content: &str) -> String {
62    // Determine the pkg-config library name
63    let lib_name = pkg_config_name_for_package(pkg_src_dir)
64        .map(String::from)
65        .or_else(|| extract_pkg_config_name_from_configure(pkg_src_dir));
66
67    let (cflags, libs) = if let Some(ref name) = lib_name {
68        // Try pkg-config
69        match pkg_config::Config::new()
70            .cargo_metadata(false)
71            .env_metadata(false)
72            .probe(name)
73        {
74            Ok(lib) => {
75                let cflags: String = lib
76                    .include_paths
77                    .iter()
78                    .map(|p| format!("-I{}", p.display()))
79                    .collect::<Vec<_>>()
80                    .join(" ");
81                let libs: String = {
82                    let mut parts: Vec<String> = lib
83                        .link_paths
84                        .iter()
85                        .map(|p| format!("-L{}", p.display()))
86                        .collect();
87                    parts.extend(lib.libs.iter().map(|l| format!("-l{l}")));
88                    parts.join(" ")
89                };
90                tracing::debug!(
91                    pkg = name,
92                    cflags = cflags.as_str(),
93                    libs = libs.as_str(),
94                    "pkg-config resolved"
95                );
96                (cflags, libs)
97            }
98            Err(e) => {
99                tracing::debug!(pkg = name, error = %e, "pkg-config failed");
100                (String::new(), String::new())
101            }
102        }
103    } else {
104        (String::new(), String::new())
105    };
106
107    // Replace @cflags@ and @libs@ placeholders
108    makevars_in_content
109        .replace("@cflags@", &cflags)
110        .replace("@libs@", &libs)
111        // Some packages use uppercase
112        .replace("@CFLAGS@", &cflags)
113        .replace("@LIBS@", &libs)
114        // stringi uses custom names
115        .replace("@STRINGI_CPPFLAGS@", &cflags)
116        .replace("@STRINGI_LIBS@", &libs)
117        .replace("@STRINGI_LDFLAGS@", "")
118        .replace("@STRINGI_CXXSTD@", "-std=c++17")
119        // Strip any remaining @...@ placeholders to avoid compiler errors
120        .split('\n')
121        .map(|line| {
122            if line.contains('@') {
123                // Replace remaining @VAR@ with empty string
124                let mut result = line.to_string();
125                while let Some(start) = result.find('@') {
126                    if let Some(end) = result[start + 1..].find('@') {
127                        result.replace_range(start..=start + 1 + end, "");
128                    } else {
129                        break;
130                    }
131                }
132                result
133            } else {
134                line.to_string()
135            }
136        })
137        .collect::<Vec<_>>()
138        .join("\n")
139}
140
141/// Emulate configure scripts for known packages that need platform-specific
142/// config.h or Makevars generation. Called before Makevars parsing.
143fn emulate_configure(pkg_src_dir: &Path) {
144    let pkg_dir = match pkg_src_dir.parent() {
145        Some(d) => d,
146        None => return,
147    };
148    let pkg_name = match pkg_dir.file_name().and_then(|n| n.to_str()) {
149        Some(n) => n,
150        None => return,
151    };
152
153    if pkg_name == "ps" {
154        emulate_configure_ps(pkg_src_dir);
155    } else if pkg_name == "fs" {
156        emulate_configure_fs(pkg_src_dir);
157    } else if pkg_name == "sass" {
158        emulate_configure_system_lib(pkg_src_dir, "libsass", "-I./libsass/include");
159    }
160}
161
162/// Generate config.h and Makevars for the `ps` package.
163fn emulate_configure_ps(pkg_src_dir: &Path) {
164    // Only generate if config.h doesn't exist
165    let config_h = pkg_src_dir.join("config.h");
166    if config_h.exists() {
167        return;
168    }
169
170    // Platform detection
171    let is_macos = cfg!(target_os = "macos");
172    let is_linux = cfg!(target_os = "linux");
173
174    let mut macros = vec![("PS__VERSION", "546")];
175    let mut objects = vec![
176        "init.o",
177        "api-common.o",
178        "common.o",
179        "extra.o",
180        "dummy.o",
181        "error-codes.o",
182        "cleancall.o",
183    ];
184
185    if is_macos || is_linux {
186        macros.push(("PS__POSIX", "1"));
187        objects.extend(&["posix.o", "api-posix.o"]);
188    }
189
190    if is_macos {
191        macros.push(("PS__MACOS", "1"));
192        // Only include objects whose source files exist
193        for obj in &[
194            "macos.o",
195            "api-macos.o",
196            "arch/macos/process_info.o",
197            "arch/macos/disk.o",
198            "arch/macos/apps.o",
199        ] {
200            let stem = obj.strip_suffix(".o").unwrap();
201            if pkg_src_dir.join(format!("{stem}.c")).is_file() {
202                objects.push(obj);
203            }
204        }
205    } else if is_linux {
206        macros.push(("PS__LINUX", "1"));
207        objects.extend(&["linux.o", "api-linux.o"]);
208    }
209
210    // Write config.h
211    let mut config = String::from("/* Generated by miniR configure emulation */\n");
212    for (name, value) in &macros {
213        config.push_str(&format!("#define {name} {value}\n"));
214    }
215    if let Err(e) = std::fs::write(&config_h, &config) {
216        tracing::warn!(error = %e, "failed to write config.h for ps");
217        return;
218    }
219
220    // Write Makevars
221    let makevars_path = pkg_src_dir.join("Makevars");
222    if !makevars_path.exists() {
223        let objects_str = objects.join(" ");
224        let makevars = format!("OBJECTS = {objects_str}\nPKG_LIBS =\n");
225        if let Err(e) = std::fs::write(&makevars_path, &makevars) {
226            tracing::warn!(error = %e, "failed to write Makevars for ps");
227        }
228    }
229
230    tracing::debug!(
231        pkg = "ps",
232        "configure emulated: config.h + Makevars generated"
233    );
234}
235
236/// Generate Makevars for `fs` package using system libuv instead of bundled.
237///
238/// The fs package bundles libuv and builds it from source using autotools.
239/// We bypass that by using the system libuv (found via pkg-config).
240fn emulate_configure_fs(pkg_src_dir: &Path) {
241    let makevars_path = pkg_src_dir.join("Makevars");
242
243    // Skip if already generated by miniR
244    if makevars_path.exists() {
245        if let Ok(content) = std::fs::read_to_string(&makevars_path) {
246            if content.contains("# Generated by miniR") {
247                return;
248            }
249        }
250    }
251
252    // Check if system libuv is available via pkg-config
253    let lib = match pkg_config::Config::new()
254        .cargo_metadata(false)
255        .env_metadata(false)
256        .probe("libuv")
257    {
258        Ok(lib) => lib,
259        Err(_) => return, // No system libuv — can't help
260    };
261
262    let cflags: String = lib
263        .include_paths
264        .iter()
265        .map(|p| format!("-I{}", p.display()))
266        .collect::<Vec<_>>()
267        .join(" ");
268    let libs: String = {
269        let mut parts: Vec<String> = lib
270            .link_paths
271            .iter()
272            .map(|p| format!("-L{}", p.display()))
273            .collect();
274        parts.extend(lib.libs.iter().map(|l| format!("-l{l}")));
275        parts.push("-lpthread".to_string());
276        parts.join(" ")
277    };
278
279    // Write a Makevars that uses system libuv instead of bundled
280    let makevars = format!(
281        "# Generated by miniR (system libuv)\n\
282         PKG_CPPFLAGS = {cflags} -I. -pthread\n\
283         PKG_LIBS = {libs}\n\
284         PKG_CFLAGS = -fvisibility=hidden\n"
285    );
286
287    if let Err(e) = std::fs::write(&makevars_path, &makevars) {
288        tracing::warn!(error = %e, "failed to write Makevars for fs");
289        return;
290    }
291    tracing::debug!(
292        cflags = cflags.as_str(),
293        libs = libs.as_str(),
294        "fs: using system libuv"
295    );
296}
297
298/// Generic configure emulation: replace a bundled static lib build with
299/// system library found via pkg-config. Writes a Makevars that uses system
300/// headers and libs instead of bundled source.
301fn emulate_configure_system_lib(
302    pkg_src_dir: &Path,
303    pkg_config_name: &str,
304    fallback_cppflags: &str,
305) {
306    let makevars_path = pkg_src_dir.join("Makevars");
307    if makevars_path.exists() {
308        let content = std::fs::read_to_string(&makevars_path).unwrap_or_default();
309        // Already generated by miniR — skip
310        if content.contains("# Generated by miniR") {
311            return;
312        }
313        // Only override if it references a bundled .a file
314        if !content.contains(".a") {
315            return;
316        }
317    }
318
319    let lib = match pkg_config::Config::new()
320        .cargo_metadata(false)
321        .env_metadata(false)
322        .probe(pkg_config_name)
323    {
324        Ok(lib) => lib,
325        Err(_) => return, // System lib not available
326    };
327
328    let cflags: String = lib
329        .include_paths
330        .iter()
331        .map(|p| format!("-I{}", p.display()))
332        .collect::<Vec<_>>()
333        .join(" ");
334    let libs: String = {
335        let mut parts: Vec<String> = lib
336            .link_paths
337            .iter()
338            .map(|p| format!("-L{}", p.display()))
339            .collect();
340        parts.extend(lib.libs.iter().map(|l| format!("-l{l}")));
341        parts.join(" ")
342    };
343
344    let makevars = format!(
345        "# Generated by miniR (pkg-config {pkg_config_name})\nPKG_CPPFLAGS = {cflags} {fallback_cppflags}\nPKG_LIBS = {libs}\n"
346    );
347
348    if let Err(e) = std::fs::write(&makevars_path, &makevars) {
349        tracing::warn!(error = %e, pkg = pkg_config_name, "failed to write Makevars");
350        return;
351    }
352    tracing::debug!(pkg = pkg_config_name, "using system library via pkg-config");
353}
354
355// endregion
356
357// region: Makevars parser
358
359/// Parsed Makevars key-value pairs.
360#[derive(Debug, Default)]
361pub struct Makevars {
362    /// All key=value pairs from the Makevars file.
363    pub vars: HashMap<String, String>,
364}
365
366impl Makevars {
367    /// Parse a Makevars file. If `Makevars` doesn't exist, falls back to
368    /// `Makevars.in` and resolves `@placeholder@` tokens via pkg-config
369    /// (replicating R's "anticonf" configure pattern).
370    pub fn parse(path: &Path) -> Self {
371        // Try Makevars first
372        if let Ok(content) = std::fs::read_to_string(path) {
373            return Self::parse_str(&content);
374        }
375
376        // Fall back to Makevars.in with placeholder resolution
377        let makevars_in = path.with_extension("in");
378        if let Ok(content) = std::fs::read_to_string(&makevars_in) {
379            let resolved = resolve_anticonf(path.parent().unwrap_or(Path::new(".")), &content);
380            return Self::parse_str(&resolved);
381        }
382
383        Makevars::default()
384    }
385
386    /// Parse Makevars content from a string.
387    ///
388    /// Handles Make variable references `$(VAR)` by expanding known R variables
389    /// and stripping unknown ones. Skips Make conditionals and build targets.
390    pub fn parse_str(content: &str) -> Self {
391        let mut vars = HashMap::new();
392        let mut continued_key: Option<String> = None;
393        let mut continued_val = String::new();
394        let mut in_conditional = 0i32; // nesting depth of ifeq/ifdef
395
396        for line in content.lines() {
397            let line = line.trim();
398
399            // Skip comments and empty lines
400            if line.starts_with('#') || line.is_empty() {
401                if continued_key.is_some() {
402                    if let Some(key) = continued_key.take() {
403                        vars.insert(key, continued_val.trim().to_string());
404                        continued_val.clear();
405                    }
406                }
407                continue;
408            }
409
410            // Handle Make conditionals — skip content inside them
411            if line.starts_with("ifeq")
412                || line.starts_with("ifdef")
413                || line.starts_with("ifneq")
414                || line.starts_with("ifndef")
415            {
416                in_conditional += 1;
417                continue;
418            }
419            if line.starts_with("endif") {
420                in_conditional = (in_conditional - 1).max(0);
421                continue;
422            }
423            if line == "else" || line.starts_with("else ") {
424                continue;
425            }
426            if in_conditional > 0 {
427                continue;
428            }
429
430            // Skip Make targets (lines with `:` before any `=`)
431            if let Some(colon_pos) = line.find(':') {
432                if let Some(eq_pos) = line.find('=') {
433                    // `:=` is an assignment, not a target
434                    if colon_pos + 1 != eq_pos
435                        && colon_pos < eq_pos
436                        && !line[..colon_pos].contains('$')
437                    {
438                        continue; // target: dependency
439                    }
440                } else {
441                    continue; // target with no assignment
442                }
443            }
444
445            // Handle line continuation
446            if let Some(ref key) = continued_key {
447                let (val, has_cont) = strip_continuation(line);
448                continued_val.push(' ');
449                continued_val.push_str(val.trim());
450                if !has_cont {
451                    vars.insert(key.clone(), continued_val.trim().to_string());
452                    continued_key = None;
453                    continued_val.clear();
454                }
455                continue;
456            }
457
458            // Try to parse as KEY = VALUE or KEY += VALUE
459            if let Some((key, op, val_part)) = parse_assignment(line) {
460                let (val, has_continuation) = strip_continuation(val_part);
461                let val = val.trim();
462
463                match op {
464                    AssignOp::Set => {
465                        if has_continuation {
466                            continued_key = Some(key.to_string());
467                            continued_val = val.trim().to_string();
468                        } else {
469                            vars.insert(key.to_string(), val.trim().to_string());
470                        }
471                    }
472                    AssignOp::Append => {
473                        let existing = vars.get(key).cloned().unwrap_or_default();
474                        let val = val.trim();
475                        let new_val = if existing.is_empty() {
476                            val.to_string()
477                        } else {
478                            format!("{existing} {val}")
479                        };
480                        if has_continuation {
481                            continued_key = Some(key.to_string());
482                            continued_val = new_val;
483                        } else {
484                            vars.insert(key.to_string(), new_val);
485                        }
486                    }
487                }
488            }
489        }
490
491        // Handle unterminated continuation
492        if let Some(key) = continued_key {
493            vars.insert(key, continued_val.trim().to_string());
494        }
495
496        // First pass: expand user-defined Makevars variables.
497        // e.g. ssdir = SuiteSparse → $(ssdir) becomes SuiteSparse in other values.
498        // Repeat until no more expansions occur (handles chained references).
499        let mut expanded = vars;
500        for _ in 0..5 {
501            let snapshot = expanded.clone();
502            let mut changed = false;
503            for value in expanded.values_mut() {
504                let mut result = String::with_capacity(value.len());
505                let mut rest = value.as_str();
506                while let Some(start) = rest.find("$(") {
507                    result.push_str(&rest[..start]);
508                    let after = &rest[start + 2..];
509                    if let Some(end) = after.find(')') {
510                        let var = &after[..end];
511                        if let Some(replacement) = snapshot.get(var) {
512                            result.push_str(replacement);
513                            changed = true;
514                        }
515                        // else: leave $(VAR) stripped (already handled by first pass)
516                        rest = &after[end + 1..];
517                    } else {
518                        result.push_str(&rest[start..]);
519                        rest = "";
520                    }
521                }
522                result.push_str(rest);
523                *value = result;
524            }
525            if !changed {
526                break;
527            }
528        }
529
530        // Second pass: expand known R variables and strip remaining $(VAR) refs
531        let final_vars: HashMap<String, String> = expanded
532            .into_iter()
533            .map(|(k, v)| (k, expand_make_vars(&v)))
534            .collect();
535
536        Makevars { vars: final_vars }
537    }
538
539    /// Get PKG_CFLAGS (additional C compiler flags).
540    pub fn pkg_cflags(&self) -> &str {
541        self.vars.get("PKG_CFLAGS").map_or("", |s| s.as_str())
542    }
543
544    /// Get PKG_CPPFLAGS (preprocessor flags like -I, -D).
545    pub fn pkg_cppflags(&self) -> &str {
546        self.vars.get("PKG_CPPFLAGS").map_or("", |s| s.as_str())
547    }
548
549    /// Get PKG_LIBS (linker flags).
550    pub fn pkg_libs(&self) -> &str {
551        self.vars.get("PKG_LIBS").map_or("", |s| s.as_str())
552    }
553
554    /// Get PKG_CXXFLAGS (C++ compiler flags).
555    pub fn pkg_cxxflags(&self) -> &str {
556        self.vars.get("PKG_CXXFLAGS").map_or("", |s| s.as_str())
557    }
558
559    /// Get OBJECTS (explicit list of .o files to link).
560    /// If not set, the default is all .c/.cpp files in src/.
561    pub fn objects(&self) -> Option<&str> {
562        self.vars.get("OBJECTS").map(|s| s.as_str())
563    }
564}
565
566#[derive(Debug, PartialEq)]
567enum AssignOp {
568    Set,
569    Append,
570}
571
572/// Parse a line as `KEY = VALUE` or `KEY += VALUE`.
573/// Returns (key, op, value_part) where value_part may have trailing `\`.
574fn parse_assignment(line: &str) -> Option<(&str, AssignOp, &str)> {
575    // Try += first (must come before = check)
576    if let Some(pos) = line.find("+=") {
577        let key = line[..pos].trim();
578        let val = &line[pos + 2..];
579        if !key.is_empty() && is_valid_makevars_key(key) {
580            return Some((key, AssignOp::Append, val));
581        }
582    }
583
584    // Try = (but not :=, which we treat the same as =)
585    if let Some(pos) = line.find('=') {
586        // Check for := (GNU Make simple expansion)
587        let (key, val) = if pos > 0 && line.as_bytes()[pos - 1] == b':' {
588            (line[..pos - 1].trim(), &line[pos + 1..])
589        } else {
590            (line[..pos].trim(), &line[pos + 1..])
591        };
592        if !key.is_empty() && is_valid_makevars_key(key) {
593            return Some((key, AssignOp::Set, val));
594        }
595    }
596
597    None
598}
599
600/// Check if a string is a valid Makevars variable name.
601fn is_valid_makevars_key(s: &str) -> bool {
602    s.chars()
603        .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
604}
605
606/// Expand Make variable references `$(VAR)` with known R values.
607/// Unknown variables are stripped (removed from the string).
608fn expand_make_vars(s: &str) -> String {
609    let mut result = String::with_capacity(s.len());
610    let mut chars = s.chars().peekable();
611
612    while let Some(ch) = chars.next() {
613        if ch == '$' && chars.peek() == Some(&'(') {
614            chars.next(); // consume '('
615                          // Collect the variable name/expression, respecting nested parens
616            let mut var_name = String::new();
617            let mut depth = 1;
618            for c in chars.by_ref() {
619                if c == '(' {
620                    depth += 1;
621                } else if c == ')' {
622                    depth -= 1;
623                    if depth == 0 {
624                        break;
625                    }
626                }
627                var_name.push(c);
628            }
629            // Expand known variables, strip unknown
630            match var_name.as_str() {
631                "C_VISIBILITY" | "CXX_VISIBILITY" => {
632                    result.push_str("-fvisibility=hidden");
633                }
634                "F_VISIBILITY" | "FPICFLAGS" | "CPICFLAGS" => {
635                    // Fortran/PIC flags — skip (handled by cc crate)
636                }
637                "SHLIB_OPENMP_CFLAGS" | "SHLIB_OPENMP_CXXFLAGS" => {
638                    // OpenMP — skip for now
639                }
640                "BLAS_LIBS" | "LAPACK_LIBS" | "FLIBS" => {
641                    // System math libraries — skip
642                }
643                // Build system variables — skip entirely
644                "CC" | "CXX" | "AR" | "RANLIB" | "MAKE" | "RM" | "SHLIB" | "STATLIB"
645                | "OBJECTS" | "LIBR" | "SHLIB_EXT" | "SHLIB_LINK" | "SHLIB_LIBADD"
646                | "SHLIB_CXXLD" | "SHLIB_CXXLDFLAGS" | "SHLIB_FFLAGS" | "CFLAGS" | "CPPFLAGS"
647                | "LDFLAGS" | "SAFE_FFLAGS" | "R_ARCH" | "R_ARCH_BIN" | "R_HOME" | "R_CC"
648                | "R_CXX" | "R_CONFIGURE_FLAGS" | "CONFIGURE_ARGS" | "ALL_CFLAGS"
649                | "ALL_CPPFLAGS" | "UNAME" | "OS" | "WIN" | "CYGWIN" | "CC_TARGET"
650                | "CLANG_CHECK" => {}
651                _ => {
652                    // Unknown variable — strip it
653                    tracing::debug!("Makevars: stripping unknown variable $({})", var_name);
654                }
655            }
656        } else {
657            result.push(ch);
658        }
659    }
660
661    // Clean up double spaces from stripped variables
662    let mut clean = result.replace("  ", " ");
663    while clean.contains("  ") {
664        clean = clean.replace("  ", " ");
665    }
666    clean.trim().to_string()
667}
668
669/// Strip trailing backslash continuation. Returns (line_without_backslash, has_continuation).
670fn strip_continuation(s: &str) -> (&str, bool) {
671    let trimmed = s.trim_end();
672    match trimmed.strip_suffix('\\') {
673        Some(without) => (without, true),
674        None => (trimmed, false),
675    }
676}
677
678// endregion
679
680// region: C/C++ compilation
681
682/// Get the current platform's target triple (e.g. "aarch64-apple-darwin").
683fn current_target_triple() -> String {
684    // Check if TARGET is set (e.g. in a Cargo build environment)
685    if let Ok(target) = std::env::var("TARGET") {
686        return target;
687    }
688    // Construct from compile-time cfg values
689    let arch = std::env::consts::ARCH;
690    let os = std::env::consts::OS;
691    // Map to standard triple format
692    let vendor_os = match os {
693        "macos" => "apple-darwin",
694        "linux" => "unknown-linux-gnu",
695        "windows" => "pc-windows-msvc",
696        "freebsd" => "unknown-freebsd",
697        other => other,
698    };
699    format!("{arch}-{vendor_os}")
700}
701
702/// Shared library extension for the current platform.
703fn dylib_ext() -> &'static str {
704    if cfg!(target_os = "macos") {
705        "dylib"
706    } else {
707        "so"
708    }
709}
710
711/// Compile C/C++ source files in a package's `src/` directory into a shared library.
712///
713/// Uses the `cc` crate for compiler detection (respects CC, CXX, CFLAGS, CXXFLAGS
714/// env vars, handles cross-compilation). The `cc` crate compiles sources to `.o`
715/// files; we then link them into a `.so`/`.dylib` ourselves.
716///
717/// # Arguments
718/// * `pkg_src_dir` — the package's `src/` directory
719/// * `pkg_name` — package name (used for the output library name)
720/// * `output_dir` — directory to write the compiled shared library
721/// * `include_dir` — path to miniR's `include/` directory (for Rinternals.h)
722///
723/// # Returns
724/// Path to the compiled shared library.
725pub fn compile_package(
726    pkg_src_dir: &Path,
727    pkg_name: &str,
728    output_dir: &Path,
729    include_dir: &Path,
730) -> Result<PathBuf, String> {
731    compile_package_with_deps(pkg_src_dir, pkg_name, output_dir, include_dir, &[])
732}
733
734/// Compile package native code with additional include paths from LinkingTo dependencies.
735pub fn compile_package_with_deps(
736    pkg_src_dir: &Path,
737    pkg_name: &str,
738    output_dir: &Path,
739    include_dir: &Path,
740    linking_to_includes: &[PathBuf],
741) -> Result<PathBuf, String> {
742    // Emulate configure for packages that need platform-specific files
743    emulate_configure(pkg_src_dir);
744
745    // Parse Makevars (falls back to Makevars.in with pkg-config resolution)
746    let makevars = Makevars::parse(&pkg_src_dir.join("Makevars"));
747
748    // Find C and C++ source files
749    let src_files = find_sources(pkg_src_dir, &makevars)?;
750    if src_files.is_empty() {
751        return Err(format!(
752            "no C/C++ source files found in {}",
753            pkg_src_dir.display()
754        ));
755    }
756
757    // Runtime is now in the binary (Rust extern "C" + C trampoline via build.rs).
758    // Package .so files resolve API symbols at load time.
759    // No minir_runtime.c needed.
760
761    // Use cc::Build for compilation — it handles compiler detection,
762    // platform flags, cross-compilation, ccache/sccache, etc.
763    //
764    // cc::Build normally runs inside Cargo build scripts where TARGET/HOST
765    // env vars are set. At runtime we set them to the current platform.
766    let target = current_target_triple();
767
768    // Split files into C, C++, and Fortran groups.
769    let mut c_files = Vec::new();
770    let mut cpp_files = Vec::new();
771    let mut fortran_files = Vec::new();
772    for f in &src_files {
773        match f.extension().and_then(|e| e.to_str()) {
774            Some("cpp" | "cc" | "cxx" | "C") => cpp_files.push(f.clone()),
775            Some("f" | "f90" | "f95" | "F" | "F90" | "F95") => fortran_files.push(f.clone()),
776            _ => c_files.push(f.clone()),
777        }
778    }
779    let has_cpp = !cpp_files.is_empty();
780    let has_fortran = !fortran_files.is_empty();
781
782    // Helper: configure common build settings
783    let configure_build = |build: &mut cc::Build| {
784        build
785            .pic(true)
786            .warnings(false)
787            .debug(true)
788            .flag("-fno-omit-frame-pointer")
789            .cargo_metadata(false) // suppress cargo:rerun-if-env-changed output
790            // Suppress function pointer type errors (common in R packages with Fortran)
791            .flag_if_supported("-Wno-incompatible-function-pointer-types")
792            .flag_if_supported("-Wno-int-conversion")
793            .flag_if_supported("-Wno-error")
794            // Fortran routine declarations: extern void F77_NAME(foo)(...) is valid
795            .flag_if_supported("-Wno-return-type")
796            .target(&target)
797            .host(&target)
798            .opt_level(2)
799            // Platform defines for packages that detect OS features at compile time
800            .define("HAVE_UNISTD_H", None)
801            .define("HAVE_GETTIMEOFDAY", None)
802            .define("HAVE_NANOSLEEP", None);
803        if cfg!(target_os = "macos") {
804            build.define("MB_HAVE_MACH_TIME", None);
805        } else {
806            build
807                .define("MB_HAVE_CLOCK_GETTIME", None)
808                .define("MB_CLOCKID_T", Some("CLOCK_REALTIME"));
809        }
810        build
811            .out_dir(output_dir)
812            .include(include_dir)
813            .include(include_dir.join("miniR"))
814            .include(pkg_src_dir);
815
816        // Add include paths from LinkingTo dependencies
817        for inc in linking_to_includes {
818            build.include(inc);
819        }
820
821        // Add PKG_CPPFLAGS (preprocessor flags, applies to both C and C++)
822        let cppflags = makevars.pkg_cppflags();
823        if !cppflags.is_empty() {
824            for flag in shell_split(cppflags) {
825                if let Some(rel_path) = flag.strip_prefix("-I") {
826                    let rel_path = rel_path.trim_matches('"').trim_matches('\'');
827                    let path = Path::new(rel_path);
828                    if path.is_relative() {
829                        build.include(pkg_src_dir.join(path));
830                    } else {
831                        build.include(path);
832                    }
833                } else {
834                    build.flag(&flag);
835                }
836            }
837        }
838    };
839
840    let mut object_files = Vec::new();
841
842    // Compile C files
843    if !c_files.is_empty() {
844        let mut c_build = cc::Build::new();
845        configure_build(&mut c_build);
846        let cflags = makevars.pkg_cflags();
847        if !cflags.is_empty() {
848            for flag in shell_split(cflags) {
849                c_build.flag(&flag);
850            }
851        }
852        for src in &c_files {
853            c_build.file(src);
854        }
855        let c_objs = c_build
856            .try_compile_intermediates()
857            .map_err(|e| format!("C compilation failed: {e}"))?;
858        object_files.extend(c_objs);
859    }
860
861    // Compile C++ files
862    if has_cpp {
863        let mut cxx_build = cc::Build::new();
864        configure_build(&mut cxx_build);
865        cxx_build.cpp(true).std("c++17");
866        let cxxflags = makevars.pkg_cxxflags();
867        if !cxxflags.is_empty() {
868            for flag in shell_split(cxxflags) {
869                // Resolve -I paths relative to pkg_src_dir (same as PKG_CPPFLAGS)
870                if let Some(rel_path) = flag.strip_prefix("-I") {
871                    let rel_path = rel_path.trim_matches('"').trim_matches('\'');
872                    let path = Path::new(rel_path);
873                    if path.is_relative() {
874                        cxx_build.include(pkg_src_dir.join(path));
875                    } else {
876                        cxx_build.include(path);
877                    }
878                } else {
879                    cxx_build.flag(&flag);
880                }
881            }
882        }
883        for src in &cpp_files {
884            cxx_build.file(src);
885        }
886        let cxx_objs = cxx_build
887            .try_compile_intermediates()
888            .map_err(|e| format!("C++ compilation failed: {e}"))?;
889        object_files.extend(cxx_objs);
890    }
891
892    // Compile Fortran files with gfortran
893    if has_fortran {
894        let gfortran = std::env::var("FC")
895            .or_else(|_| std::env::var("F77"))
896            .unwrap_or_else(|_| "gfortran".to_string());
897
898        // Parse PKG_FFLAGS from Makevars
899        let fflags = makevars.vars.get("PKG_FFLAGS").cloned().unwrap_or_default();
900
901        for src in &fortran_files {
902            let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("f");
903            let obj_path = output_dir.join(format!("{stem}.o"));
904            let mut cmd = Command::new(&gfortran);
905            cmd.arg("-c")
906                .arg("-fPIC")
907                .arg("-O2")
908                // Write .mod files to output_dir, not cwd
909                .arg(format!("-J{}", output_dir.display()))
910                .arg("-o")
911                .arg(&obj_path);
912            // Add PKG_FFLAGS if present
913            for flag in shell_split(&fflags) {
914                cmd.arg(&flag);
915            }
916            cmd.arg(src);
917            let output = cmd
918                .output()
919                .map_err(|e| format!("failed to run gfortran: {e}"))?;
920            if !output.status.success() {
921                let stderr = String::from_utf8_lossy(&output.stderr);
922                return Err(format!(
923                    "Fortran compilation failed for {}:\n{stderr}",
924                    src.display()
925                ));
926            }
927            object_files.push(obj_path);
928        }
929    }
930
931    // Link .o files into a shared library (.so/.dylib)
932    // Use the C++ compiler as linker if any C++ files were compiled.
933    let linker_build = if has_cpp {
934        let mut b = cc::Build::new();
935        b.cpp(true)
936            .cargo_metadata(false)
937            .target(&target)
938            .host(&target)
939            .opt_level(2);
940        b
941    } else {
942        let mut b = cc::Build::new();
943        b.cargo_metadata(false)
944            .target(&target)
945            .host(&target)
946            .opt_level(2);
947        b
948    };
949    let linker = linker_build
950        .try_get_compiler()
951        .map_err(|e| format!("cannot find compiler: {e}"))?;
952
953    let lib_name = format!("{pkg_name}.{}", dylib_ext());
954    let lib_path = output_dir.join(&lib_name);
955
956    let mut cmd = Command::new(linker.path());
957    cmd.arg("-shared").arg("-o").arg(&lib_path);
958
959    for obj in &object_files {
960        cmd.arg(obj);
961    }
962
963    // Platform-specific flags
964    if cfg!(target_os = "macos") {
965        cmd.arg("-undefined").arg("dynamic_lookup");
966        // Link against Accelerate framework for real BLAS/LAPACK
967        cmd.arg("-framework").arg("Accelerate");
968    }
969
970    // C++ runtime linking
971    if has_cpp {
972        if cfg!(target_os = "macos") {
973            cmd.arg("-lc++");
974        } else {
975            cmd.arg("-lstdc++");
976        }
977    }
978
979    // Fortran runtime linking — find libgfortran via gfortran itself
980    if has_fortran {
981        let gfortran = std::env::var("FC")
982            .or_else(|_| std::env::var("F77"))
983            .unwrap_or_else(|_| "gfortran".to_string());
984        // Ask gfortran where its runtime library lives
985        if let Ok(output) = Command::new(&gfortran)
986            .arg("-print-file-name=libgfortran.dylib")
987            .output()
988        {
989            if output.status.success() {
990                let lib_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
991                if let Some(dir) = Path::new(&lib_path).parent() {
992                    cmd.arg(format!("-L{}", dir.display()));
993                }
994            }
995        }
996        cmd.arg("-lgfortran");
997    }
998
999    // Add PKG_LIBS (linker flags) — skip local -L/-l for bundled static libs
1000    // since we compile all sources directly into the .so.
1001    // If PKG_LIBS references a local -L path, ALL -l flags from PKG_LIBS are
1002    // likely bundled and should be skipped.
1003    let libs = makevars.pkg_libs();
1004    if !libs.is_empty() {
1005        let flags = shell_split(libs);
1006        let has_local_lib = flags
1007            .iter()
1008            .any(|f| f.starts_with("-L") && !f.starts_with("-L/"));
1009        for flag in &flags {
1010            if flag.starts_with("-L") && !flag.starts_with("-L/") {
1011                continue; // skip local -L paths
1012            }
1013            if has_local_lib && flag.starts_with("-l") {
1014                continue; // skip all -l when using local lib paths
1015            }
1016            // Resolve .o/.a file references relative to pkg_src_dir.
1017            // If the referenced .o doesn't exist, try compiling its .c source.
1018            if flag.ends_with(".o") || flag.ends_with(".a") {
1019                let obj_path = if Path::new(flag).is_relative() {
1020                    pkg_src_dir.join(flag)
1021                } else {
1022                    PathBuf::from(flag)
1023                };
1024                if !obj_path.exists() && flag.ends_with(".o") {
1025                    // Try to compile the corresponding .c file
1026                    let c_src = obj_path.with_extension("c");
1027                    if c_src.is_file() {
1028                        let mut obj_build = cc::Build::new();
1029                        configure_build(&mut obj_build);
1030                        obj_build.file(&c_src);
1031                        if let Ok(objs) = obj_build.try_compile_intermediates() {
1032                            object_files.extend(objs);
1033                            continue; // compiled and added to objects, skip the flag
1034                        }
1035                    }
1036                }
1037                if obj_path.exists() {
1038                    cmd.arg(&obj_path);
1039                }
1040                continue;
1041            }
1042            cmd.arg(flag);
1043        }
1044    }
1045
1046    let output = cmd
1047        .output()
1048        .map_err(|e| format!("failed to run linker: {e}"))?;
1049
1050    if !output.status.success() {
1051        let stderr = String::from_utf8_lossy(&output.stderr);
1052        return Err(format!("linking {lib_name} failed:\n{stderr}"));
1053    }
1054
1055    Ok(lib_path)
1056}
1057
1058/// Find C and C++ source files to compile.
1059///
1060/// Collects sources from:
1061/// 1. `OBJECTS` variable (if set) — explicit list
1062/// 2. Other variables ending in `.o` — bundled library object files
1063/// 3. Fallback: all `src/*.{c,cpp,cc,cxx}` files
1064///
1065/// For bundled libraries (jsonlite/yajl, commonmark/cmark, etc.), the Makevars
1066/// defines variables like `LIBYAJL = yajl/yajl.o yajl/yajl_alloc.o ...`.
1067/// We collect ALL `.o` references, resolve them to source files, and compile
1068/// everything into one shared library (no static archive intermediate).
1069fn find_sources(src_dir: &Path, makevars: &Makevars) -> Result<Vec<PathBuf>, String> {
1070    // Collect all .o file references from ALL Makevars variables
1071    let mut all_objects: Vec<String> = Vec::new();
1072
1073    for (key, value) in &makevars.vars {
1074        // Skip non-object variables
1075        if matches!(
1076            key.as_str(),
1077            "PKG_CFLAGS" | "PKG_CPPFLAGS" | "PKG_CXXFLAGS" | "PKG_LIBS" | "CXX_STD"
1078        ) {
1079            continue;
1080        }
1081        // Extract .o file references from the value
1082        for token in shell_split(value) {
1083            let token = token.trim().to_string();
1084            if token.ends_with(".o") {
1085                all_objects.push(token);
1086            }
1087        }
1088    }
1089
1090    if !all_objects.is_empty() {
1091        // Resolve .o files to source files
1092        let mut sources = Vec::new();
1093        let mut seen = std::collections::HashSet::new();
1094        for obj in &all_objects {
1095            let stem = if let Some(s) = obj.strip_suffix(".o") {
1096                s
1097            } else {
1098                continue;
1099            };
1100            for ext in &["c", "cpp", "cc", "cxx", "f", "f90", "f95"] {
1101                let path = src_dir.join(format!("{stem}.{ext}"));
1102                if path.is_file() && seen.insert(path.clone()) {
1103                    sources.push(path);
1104                    break;
1105                }
1106            }
1107        }
1108
1109        // Only add top-level sources if the OBJECTS list doesn't come from
1110        // a generated Makevars (which has explicit per-platform file lists).
1111        // Check: if OBJECTS was set explicitly, trust it completely.
1112        let has_explicit_objects = makevars.vars.contains_key("OBJECTS");
1113        if !has_explicit_objects {
1114            // Add any top-level .c/.cpp files not already included
1115            // (some packages have both bundled libs AND top-level sources)
1116            if let Ok(entries) = std::fs::read_dir(src_dir) {
1117                for entry in entries.flatten() {
1118                    let path = entry.path();
1119                    if path.is_file() {
1120                        if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
1121                            if matches!(ext, "c" | "cpp" | "cc" | "cxx" | "f" | "f90" | "f95")
1122                                && seen.insert(path.clone())
1123                            {
1124                                sources.push(path);
1125                            }
1126                        }
1127                    }
1128                }
1129            }
1130        }
1131
1132        sources.sort();
1133        Ok(sources)
1134    } else {
1135        // Default: all C/C++ source files in src/ (non-recursive)
1136        let mut sources = Vec::new();
1137        let entries = std::fs::read_dir(src_dir)
1138            .map_err(|e| format!("cannot read {}: {e}", src_dir.display()))?;
1139        for entry in entries {
1140            let entry = entry.map_err(|e| format!("readdir error: {e}"))?;
1141            let path = entry.path();
1142            if path.is_file() {
1143                if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
1144                    if matches!(ext, "c" | "cpp" | "cc" | "cxx" | "f" | "f90" | "f95") {
1145                        sources.push(path);
1146                    }
1147                }
1148            }
1149        }
1150        sources.sort();
1151        Ok(sources)
1152    }
1153}
1154
1155/// Simple shell-like splitting of a string into words.
1156/// Handles basic quoting (double quotes) but not single quotes or escapes.
1157fn shell_split(s: &str) -> Vec<String> {
1158    let mut result = Vec::new();
1159    let mut current = String::new();
1160    let mut in_quotes = false;
1161
1162    for ch in s.chars() {
1163        match ch {
1164            '"' => in_quotes = !in_quotes,
1165            ' ' | '\t' if !in_quotes => {
1166                if !current.is_empty() {
1167                    result.push(std::mem::take(&mut current));
1168                }
1169            }
1170            _ => current.push(ch),
1171        }
1172    }
1173    if !current.is_empty() {
1174        result.push(current);
1175    }
1176    result
1177}
1178
1179// endregion
1180
1181#[cfg(test)]
1182mod tests {
1183    use super::*;
1184
1185    #[test]
1186    fn parse_simple_makevars() {
1187        let content = r#"
1188# Package flags
1189PKG_CFLAGS = -Wall -O2
1190PKG_LIBS = -lz
1191OBJECTS = foo.o bar.o
1192"#;
1193        let mv = Makevars::parse_str(content);
1194        assert_eq!(mv.pkg_cflags(), "-Wall -O2");
1195        assert_eq!(mv.pkg_libs(), "-lz");
1196        assert_eq!(mv.objects(), Some("foo.o bar.o"));
1197    }
1198
1199    #[test]
1200    fn parse_continuation_lines() {
1201        let content = "PKG_CFLAGS = -Wall \\\n  -O2 \\\n  -Wextra\n";
1202        let mv = Makevars::parse_str(content);
1203        assert_eq!(mv.pkg_cflags(), "-Wall -O2 -Wextra");
1204    }
1205
1206    #[test]
1207    fn parse_append_operator() {
1208        let content = "PKG_CFLAGS = -Wall\nPKG_CFLAGS += -O2\n";
1209        let mv = Makevars::parse_str(content);
1210        assert_eq!(mv.pkg_cflags(), "-Wall -O2");
1211    }
1212
1213    #[test]
1214    fn parse_colon_equals() {
1215        let content = "PKG_CFLAGS := -Wall\n";
1216        let mv = Makevars::parse_str(content);
1217        assert_eq!(mv.pkg_cflags(), "-Wall");
1218    }
1219
1220    #[test]
1221    fn empty_makevars() {
1222        let mv = Makevars::parse_str("");
1223        assert_eq!(mv.pkg_cflags(), "");
1224        assert_eq!(mv.pkg_libs(), "");
1225        assert!(mv.objects().is_none());
1226    }
1227
1228    #[test]
1229    fn shell_split_basic() {
1230        assert_eq!(shell_split("-Wall -O2"), vec!["-Wall", "-O2"]);
1231        assert_eq!(shell_split("  -I/usr/include  "), vec!["-I/usr/include"]);
1232        assert_eq!(shell_split(r#"-DFOO="bar baz""#), vec!["-DFOO=bar baz"]);
1233    }
1234}