Skip to main content

gruel_compiler/
link.rs

1//! Linking object files into the final executable.
2//!
3//! Driver for the system-linker path: collects object files into a temporary
4//! directory, drops the embedded runtime archive next to them, and shells out
5//! to `cc` / `clang` / `ld` (whatever the user picked). Cleanup is automatic
6//! via `Drop` on `TempLinkDir`, so early returns from errors don't leak.
7
8use std::io::Write;
9use std::path::PathBuf;
10use std::process::Command;
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use tracing::{info, info_span};
14
15use gruel_util::{
16    CompileError, CompileErrors, CompileResult, CompileWarning, ErrorKind, MultiErrorResult,
17};
18
19use crate::{CompileOptions, CompileOutput};
20
21/// Build a `LinkError` from an `io::Error` and a context string.
22fn io_link_error(context: &str, err: std::io::Error) -> CompileError {
23    CompileError::without_span(ErrorKind::LinkError(format!("{}: {}", context, err)))
24}
25
26/// Counter for generating unique temp directory names across parallel test runs.
27static TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
28
29/// The gruel-runtime staticlib archive bytes, embedded at compile time.
30/// Linked into every Gruel executable.
31static RUNTIME_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libgruel_runtime.a"));
32
33/// A temporary directory for linking that automatically cleans up on drop.
34///
35/// Holds object files plus the runtime archive. The directory is removed
36/// when `TempLinkDir` is dropped (whether via normal completion or early
37/// error return), so callers can use `?` freely without leaking files.
38struct TempLinkDir {
39    path: PathBuf,
40    obj_paths: Vec<PathBuf>,
41    runtime_path: PathBuf,
42    output_path: PathBuf,
43}
44
45impl TempLinkDir {
46    fn new() -> CompileResult<Self> {
47        let unique_id = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
48        let path = std::env::temp_dir().join(format!("gruel-{}-{}", std::process::id(), unique_id));
49        std::fs::create_dir_all(&path)
50            .map_err(|e| io_link_error("failed to create temp directory", e))?;
51
52        let runtime_path = path.join("libgruel_runtime.a");
53        let output_path = path.join("output");
54
55        Ok(Self {
56            path,
57            obj_paths: Vec::new(),
58            runtime_path,
59            output_path,
60        })
61    }
62
63    fn write_object_files(&mut self, object_files: &[Vec<u8>]) -> CompileResult<()> {
64        for (i, obj_bytes) in object_files.iter().enumerate() {
65            let obj_path = self.path.join(format!("obj{}.o", i));
66            let mut file = std::fs::File::create(&obj_path)
67                .map_err(|e| io_link_error("failed to create temp object file", e))?;
68            file.write_all(obj_bytes)
69                .map_err(|e| io_link_error("failed to write temp object file", e))?;
70            self.obj_paths.push(obj_path);
71        }
72        Ok(())
73    }
74
75    fn write_runtime(&self, runtime_bytes: &[u8]) -> CompileResult<()> {
76        std::fs::write(&self.runtime_path, runtime_bytes)
77            .map_err(|e| io_link_error("failed to write runtime archive", e))
78    }
79
80    fn read_output(&self) -> CompileResult<Vec<u8>> {
81        std::fs::read(&self.output_path)
82            .map_err(|e| io_link_error("failed to read linked executable", e))
83    }
84}
85
86impl Drop for TempLinkDir {
87    fn drop(&mut self) {
88        let _ = std::fs::remove_dir_all(&self.path);
89    }
90}
91
92/// Which linker to use for the final linking phase.
93///
94/// The Gruel compiler can either use its built-in ELF linker or delegate to
95/// an external system linker like `clang`, `gcc`, or `ld`.
96#[derive(Debug, Clone, PartialEq, Eq, Default)]
97pub enum LinkerMode {
98    /// Use the internal linker (default).
99    #[default]
100    Internal,
101    /// Use an external system linker (e.g., `"clang"`, `"ld"`, `"gcc"`).
102    System(String),
103}
104
105/// ADR-0086: per-library linkage mode declared via `link_extern` /
106/// `static_link_extern`. Threaded through to `link_system_with_warnings`
107/// so the linker line can emit `-Wl,-Bstatic` brackets (ELF) or
108/// `-Wl,-search_paths_first` (Mach-O) around static libraries.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum LinkMode {
111    /// `link_extern("name")` — `-l<name>` on the link line.
112    #[default]
113    Dynamic,
114    /// `static_link_extern("name")` — `-Wl,-Bstatic -l<name>
115    /// -Wl,-Bdynamic` on ELF; `-Wl,-search_paths_first -l<name>` on
116    /// Mach-O (falls back to dynamic if no `.a` is found; the warning
117    /// fires through `CompileWarning::StaticLinkMachoFallback`).
118    Static,
119}
120
121/// Link object files into a final executable using an external system linker.
122pub(crate) fn link_system_with_warnings(
123    options: &CompileOptions,
124    object_files: &[Vec<u8>],
125    linker_cmd: &str,
126    warnings: &[CompileWarning],
127    extra_link_libraries: &[(String, LinkMode)],
128) -> MultiErrorResult<CompileOutput> {
129    let _span = info_span!("linker", mode = "system", command = linker_cmd).entered();
130
131    let mut temp_dir = TempLinkDir::new().map_err(CompileErrors::from)?;
132    temp_dir
133        .write_object_files(object_files)
134        .map_err(CompileErrors::from)?;
135    temp_dir
136        .write_runtime(RUNTIME_BYTES)
137        .map_err(CompileErrors::from)?;
138
139    let mut cmd = Command::new(linker_cmd);
140
141    // We pass `-nostartfiles` (not `-nostdlib`) because the runtime provides
142    // its own `_start` / `__main` entry but still relies on libc for
143    // syscalls. On Linux, dynamic linking lets `ld.so` initialize libc
144    // (TLS, malloc, stdio) before we jump into our entry.
145    if options.target.is_macho() {
146        cmd.arg("-nostartfiles");
147        cmd.arg("-arch").arg("arm64");
148        cmd.arg("-e").arg("__main");
149    } else {
150        cmd.arg("-nostartfiles");
151    }
152
153    cmd.arg("-o");
154    cmd.arg(&temp_dir.output_path);
155    for path in &temp_dir.obj_paths {
156        cmd.arg(path);
157    }
158    cmd.arg(&temp_dir.runtime_path);
159
160    if options.target.is_macho() {
161        cmd.arg("-lSystem");
162    }
163
164    // ADR-0085 + ADR-0086: emit `-l<name>` for each user-declared library.
165    // Static-linked libraries get platform-specific bracketing:
166    //   - ELF: `-Wl,-Bstatic -l<name> -Wl,-Bdynamic`
167    //   - Mach-O: `-Wl,-search_paths_first -l<name>` (best-effort)
168    let is_macho = options.target.is_macho();
169    let any_static = extra_link_libraries
170        .iter()
171        .any(|(_, mode)| *mode == LinkMode::Static);
172    // Sort static libs first so the ELF `-Bstatic` bracket is one
173    // contiguous run, dynamic libs after. Within each group, names
174    // are lex-sorted (already true from the BTreeMap upstream).
175    let mut static_libs: Vec<&str> = Vec::new();
176    let mut dynamic_libs: Vec<&str> = Vec::new();
177    for (name, mode) in extra_link_libraries {
178        match mode {
179            LinkMode::Static => static_libs.push(name.as_str()),
180            LinkMode::Dynamic => dynamic_libs.push(name.as_str()),
181        }
182    }
183    if any_static {
184        if is_macho {
185            // Mach-O lacks an ELF-style `-Bstatic`/`-Bdynamic` toggle.
186            // `-search_paths_first` makes the linker prefer `.a` over
187            // `.dylib` when both are on the search path — closest fit.
188            cmd.arg("-Wl,-search_paths_first");
189            for name in &static_libs {
190                cmd.arg(format!("-l{}", name));
191            }
192        } else {
193            cmd.arg("-Wl,-Bstatic");
194            for name in &static_libs {
195                cmd.arg(format!("-l{}", name));
196            }
197            cmd.arg("-Wl,-Bdynamic");
198        }
199    }
200    for name in &dynamic_libs {
201        cmd.arg(format!("-l{}", name));
202    }
203
204    let output = cmd.output().map_err(|e| {
205        CompileErrors::from(CompileError::without_span(ErrorKind::LinkError(format!(
206            "failed to execute linker '{}': {}",
207            linker_cmd, e
208        ))))
209    })?;
210
211    if !output.status.success() {
212        let stderr = String::from_utf8_lossy(&output.stderr);
213        return Err(CompileErrors::from(CompileError::without_span(
214            ErrorKind::LinkError(format!("linker '{}' failed: {}", linker_cmd, stderr)),
215        )));
216    }
217
218    let elf = temp_dir.read_output().map_err(CompileErrors::from)?;
219    info!(
220        object_count = object_files.len(),
221        output_bytes = elf.len(),
222        "linking complete"
223    );
224
225    Ok(CompileOutput {
226        elf,
227        warnings: warnings.to_vec(),
228    })
229}