Skip to main content

lux_lib/operations/
dist_bin.rs

1use std::{
2    io,
3    path::{Path, PathBuf},
4    process::Stdio,
5};
6
7use bon::Builder;
8use itertools::Itertools;
9use thiserror::Error;
10use walkdir::WalkDir;
11
12use crate::{
13    build::utils::c_dylib_extension,
14    config::Config,
15    fs,
16    lua_installation::{LuaInstallation, LuaInstallationError},
17    lua_rockspec::LuaModule,
18    operations::{InstallProject, InstallProjectError},
19    project::{project_toml::LocalProjectTomlValidationError, Project},
20    rockspec::Rockspec,
21    tree::{InstallTree, TreeError},
22};
23
24/// Compile a Lux project and all its dependencies into a single
25/// static binary that does not require a Lua installation.
26///
27/// Based on [luastatic](https://github.com/ers35/luastatic)
28#[derive(Builder)]
29#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
30pub struct DistProjectBin<'a, T>
31where
32    T: InstallTree,
33{
34    /// The project to compile.
35    project: &'a Project,
36
37    config: &'a Config,
38
39    /// Tree in which to install the project before compiling.
40    tree: &'a T,
41
42    /// Destination path for the compiled binary.
43    /// Defaults to `<cwd>/<package>[.exe]`.
44    output: Option<PathBuf>,
45}
46
47use miette::Diagnostic;
48#[derive(Error, Debug, Diagnostic)]
49#[non_exhaustive]
50pub enum DistProjectBinError {
51    #[error("error installing project:\n{0}")]
52    #[diagnostic(forward(0))]
53    InstallProject(#[from] InstallProjectError),
54    #[error(transparent)]
55    #[diagnostic(transparent)]
56    LocalProjectTomlValidation(#[from] LocalProjectTomlValidationError),
57    #[error(transparent)]
58    #[diagnostic(transparent)]
59    Tree(#[from] TreeError),
60    #[cfg(not(target_os = "linux"))]
61    #[error(
62        r#"Lua binary libraries are only linkable on Linux.
63Cannot link the following binaries:
64{0}"#
65    )]
66    CannotLinkBinaryLibs(String),
67    #[error(transparent)]
68    #[diagnostic(transparent)]
69    LuaInstallation(#[from] LuaInstallationError),
70    #[error(transparent)]
71    CC(#[from] cc::Error),
72    #[error(transparent)]
73    Io(#[from] io::Error),
74    #[error(transparent)]
75    #[diagnostic(transparent)]
76    Fs(#[from] fs::FsError),
77    #[error("C compilation failed (exit {status}):\nstdout: {stdout}\nstderr: {stderr}")]
78    CompilationFailed {
79        status: std::process::ExitStatus,
80        stdout: String,
81        stderr: String,
82    },
83}
84
85impl<T, State> DistProjectBinBuilder<'_, T, State>
86where
87    T: InstallTree + Sync + Send + Clone + 'static,
88    State: dist_project_bin_builder::State + dist_project_bin_builder::IsComplete,
89{
90    pub async fn compile(self) -> Result<PathBuf, DistProjectBinError> {
91        do_dist_project_bin(self._build()).await
92    }
93}
94
95/// Files collected from an installed tree for binary compilation.
96#[derive(Debug)]
97struct InstalledFiles {
98    /// Lua source files (.lua) to embed.
99    src: Vec<(LuaModule, PathBuf)>,
100    /// Native Lua modules (.so/.dll/.dylib) to link.
101    lib: Vec<PathBuf>,
102}
103
104#[tracing::instrument(name = "Distributing binary", skip_all)]
105
106async fn do_dist_project_bin<T>(args: DistProjectBin<'_, T>) -> Result<PathBuf, DistProjectBinError>
107where
108    T: InstallTree + Sync + Send + Clone + 'static,
109{
110    let package = InstallProject::new()
111        .project(args.project)
112        .config(args.config)
113        .tree(args.tree)
114        .build()
115        .await?;
116
117    let files = collect_installed_files(args.tree)?;
118
119    let project_toml = args.project.toml().into_local()?;
120    let pkg_name = project_toml.package().to_string();
121
122    let entrypoint_module = project_toml
123        .run()
124        .and_then(|r| r.current_platform().args.as_ref())
125        .map(|args| args.first().as_str())
126        .map(entrypoint_stem)
127        .unwrap_or_else(|| pkg_name.clone());
128
129    let layout = args.tree.installed_rock_layout(&package)?;
130
131    let lua = LuaInstallation::new_from_config(args.config).await?;
132
133    let output = args.output.unwrap_or_else(|| {
134        let mut p = PathBuf::from(&pkg_name);
135        if cfg!(target_env = "msvc") {
136            p.set_extension("exe");
137        }
138        p
139    });
140
141    let lib_root = layout.lib.clone();
142    let c_src = generate_c_source(&entrypoint_module, &files, &lib_root).await?;
143
144    let work_dir = fs::tempfile::tempdir()?;
145    let c_path = work_dir.path().join(format!("{pkg_name}.static.c"));
146    fs::tokio::write(&c_path, &c_src).await?;
147
148    compile_binary(&c_path, &output, &lua, &files.lib, &work_dir, args.config).await?;
149
150    Ok(output)
151}
152
153#[allow(clippy::result_large_err)]
154fn collect_installed_files(tree: &impl InstallTree) -> Result<InstalledFiles, DistProjectBinError> {
155    let mut lua_sources = Vec::new();
156    let mut native_modules = Vec::new();
157    let c_dylib_ext = c_dylib_extension();
158
159    for package in tree.list()?.values().flatten() {
160        let layout = tree.installed_rock_layout(package)?;
161
162        if layout.src.is_dir() {
163            let src_canonical = layout.src.canonicalize().unwrap_or(layout.src.clone());
164            for path in WalkDir::new(&src_canonical)
165                .into_iter()
166                .filter_map(|e| e.ok())
167                .map(|e| e.into_path())
168                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "lua"))
169            {
170                let rel = path
171                    .strip_prefix(&src_canonical)
172                    .unwrap_or(&path)
173                    .with_extension("");
174                if let Ok(module) = LuaModule::from_pathbuf(rel) {
175                    lua_sources.push((module, path));
176                }
177            }
178        }
179
180        if layout.lib.is_dir() {
181            let lib_canononical = layout.lib.canonicalize().unwrap_or(layout.lib.clone());
182            for path in WalkDir::new(&lib_canononical)
183                .into_iter()
184                .filter_map(|e| e.ok())
185                .map(|e| e.into_path())
186                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == c_dylib_ext))
187            {
188                native_modules.push(path);
189            }
190        }
191    }
192    #[cfg(not(target_os = "linux"))]
193    if !native_modules.is_empty() {
194        return Err(DistProjectBinError::CannotLinkBinaryLibs(
195            native_modules
196                .iter()
197                .unique()
198                .map(|p| p.to_string_lossy())
199                .join("\n"),
200        ));
201    }
202
203    // NOTE: A `FlatDistTree` can produce duplicates, as all modules share the same `src`.
204    Ok(InstalledFiles {
205        src: lua_sources.into_iter().unique().collect(),
206        lib: native_modules.into_iter().unique().collect(),
207    })
208}
209
210/// Derive a module stem from a run-spec arg like `"src/main.lua"` -> `"main"`.
211fn entrypoint_stem(arg: &str) -> String {
212    PathBuf::from(arg)
213        .file_stem()
214        .map(|s| s.to_string_lossy().into_owned())
215        .unwrap_or_else(|| arg.to_owned())
216}
217
218/// Derive the Lua module name from an installed lib path.
219/// e.g. `<tree>/lib/foo/bar.so` -> `("foo.bar", "foo_bar")`.
220fn module_names(lib_root: &Path, path: &Path) -> (String, String) {
221    let rel = path.strip_prefix(lib_root).unwrap_or(path);
222    let dotpath = rel
223        .with_extension("")
224        .to_string_lossy()
225        .replace(std::path::MAIN_SEPARATOR, ".");
226    let underscore = dotpath.replace(['.', '-'], "_");
227    (dotpath, underscore)
228}
229
230/// Generate a C source file embedding all Lua sources.
231async fn generate_c_source(
232    entrypoint_module: &str,
233    files: &InstalledFiles,
234    lib_root: &Path,
235) -> Result<String, fs::FsError> {
236    let mut out = String::from(C_PREAMBLE);
237
238    out.push_str("#ifdef __cplusplus\nextern \"C\" {\n#endif\n");
239    for path in &files.lib {
240        let (_, underscore) = module_names(lib_root, path);
241        out.push_str(&format!("int luaopen_{underscore}(lua_State *L);\n"));
242    }
243    out.push_str("#ifdef __cplusplus\n}\n#endif\n\n");
244
245    for (i, (_, path)) in files.src.iter().enumerate() {
246        let bytes = fs::tokio::read(path).await?;
247        let hex = bytes_to_hex(&bytes);
248        out.push_str(&format!(
249            "static const unsigned char lua_src_{i}[] = {{{hex}}};\n"
250        ));
251    }
252
253    let loader_with_entrypoint = format!(
254        "{LUA_LOADER_SOURCE}local func = lua_loader(\"{entrypoint_module}\")\n\
255         if type(func) == \"function\" then\n\
256         \tfunc(unpack(arg))\n\
257         else\n\
258         \terror(func, 0)\n\
259         end\n"
260    );
261    let loader_hex = bytes_to_hex(loader_with_entrypoint.as_bytes());
262    out.push_str(&format!(
263        "static const unsigned char lua_loader_program[] = {{{loader_hex}}};\n\n"
264    ));
265
266    out.push_str("int main(int argc, char *argv[]) {\n");
267    out.push_str("  lua_State *L = luaL_newstate();\n");
268    out.push_str("  luaL_openlibs(L);\n");
269    out.push_str("  createargtable(L, argv, argc, 0);\n\n");
270
271    out.push_str(&format!(
272        "  if (luaL_loadbuffer(L, (const char*)lua_loader_program, sizeof(lua_loader_program), \"{entrypoint_module}\") != LUA_OK) {{\n"
273    ));
274    out.push_str("    fprintf(stderr, \"luaL_loadbuffer: %s\\n\", lua_tostring(L, -1));\n");
275    out.push_str("    lua_close(L); return 1;\n  }\n\n");
276
277    out.push_str("  /* lua_bundle */\n  lua_newtable(L);\n");
278
279    for (i, (module, _)) in files.src.iter().enumerate() {
280        out.push_str(&format!(
281            "  lua_pushlstring(L, (const char*)lua_src_{i}, sizeof(lua_src_{i}));\n"
282        ));
283        out.push_str(&format!("  lua_setfield(L, -2, \"{module}\");\n"));
284    }
285
286    for path in &files.lib {
287        let (dotpath, underscore) = module_names(lib_root, path);
288        out.push_str(&format!("  lua_pushcfunction(L, luaopen_{underscore});\n"));
289        out.push_str(&format!("  lua_setfield(L, -2, \"{dotpath}\");\n"));
290    }
291
292    out.push_str("\n  if (docall(L, 1, LUA_MULTRET)) {\n");
293    out.push_str("    const char *msg = lua_tostring(L, 1);\n");
294    out.push_str("    if (msg) fprintf(stderr, \"%s\\n\", msg);\n");
295    out.push_str("    lua_close(L); return 1;\n  }\n");
296    out.push_str("  lua_close(L);\n  return 0;\n}\n");
297
298    Ok(out)
299}
300
301fn bytes_to_hex(bytes: &[u8]) -> String {
302    bytes
303        .iter()
304        .map(|b| format!("0x{b:02x}"))
305        .collect::<Vec<_>>()
306        .join(", ")
307}
308
309async fn compile_binary(
310    c_path: &Path,
311    output: &Path,
312    lua: &LuaInstallation,
313    native_modules: &[PathBuf],
314    work_dir: &tempfile::TempDir,
315    config: &Config,
316) -> Result<(), DistProjectBinError> {
317    let mut build = cc::Build::new();
318    let host = target_lexicon::Triple::host().to_string();
319
320    let intermediate_dir = fs::tempfile::tempdir()?;
321    build
322        .cargo_output(false)
323        .cargo_metadata(false)
324        .cargo_warnings(false)
325        .warnings(config.verbose())
326        .host(&host)
327        .target(&host)
328        .opt_level(3)
329        .out_dir(&intermediate_dir);
330
331    let compiler = build.try_get_compiler()?;
332
333    let is_msvc = compiler.is_like_msvc();
334    // Suppress all warnings
335    if is_msvc {
336        build.flag("-W0");
337    } else {
338        build.flag("-w");
339    }
340
341    let mut cmd: tokio::process::Command = compiler.to_command().into();
342    cmd.current_dir(work_dir.path());
343    cmd.arg(c_path);
344
345    for include in lua.includes() {
346        cmd.arg(format!("-I{}", include.display()));
347    }
348    cmd.args(native_modules);
349    cmd.arg("-o").arg(output);
350    cmd.args(lua.lib_link_args(&compiler));
351
352    #[cfg(not(target_env = "msvc"))]
353    {
354        cmd.arg("-rdynamic");
355        cmd.arg("-lm");
356        // Link with libdl because liblua was built with support loading
357        // shared objects and the operating system depends on it.
358        #[cfg(target_family = "unix")]
359        cmd.arg("-ldl");
360    }
361
362    let out = cmd
363        .stdout(Stdio::piped())
364        .stderr(Stdio::piped())
365        .output()
366        .await?;
367
368    if !out.status.success() {
369        return Err(DistProjectBinError::CompilationFailed {
370            status: out.status,
371            stdout: String::from_utf8_lossy(&out.stdout).into(),
372            stderr: String::from_utf8_lossy(&out.stderr).into(),
373        });
374    }
375
376    Ok(())
377}
378
379const C_PREAMBLE: &str = r#"
380#ifdef __cplusplus
381extern "C" {
382#endif
383#include <lauxlib.h>
384#include <lua.h>
385#include <lualib.h>
386#ifdef __cplusplus
387}
388#endif
389#include <signal.h>
390#include <stdio.h>
391#include <stdlib.h>
392#include <string.h>
393
394#if LUA_VERSION_NUM == 501
395  #define LUA_OK 0
396#endif
397
398static lua_State *globalL = NULL;
399
400static void lstop(lua_State *L, lua_Debug *ar) {
401  (void)ar;
402  lua_sethook(L, NULL, 0, 0);
403  luaL_error(L, "interrupted!");
404}
405
406static void laction(int i) {
407  signal(i, SIG_DFL);
408  lua_sethook(globalL, lstop, LUA_MASKCALL | LUA_MASKRET | LUA_MASKCOUNT, 1);
409}
410
411static void createargtable(lua_State *L, char **argv, int argc, int script) {
412  int i, narg;
413  if (script == argc) script = 0;
414  narg = argc - (script + 1);
415  lua_createtable(L, narg, script + 1);
416  for (i = 0; i < argc; i++) {
417    lua_pushstring(L, argv[i]);
418    lua_rawseti(L, -2, i - script);
419  }
420  lua_setglobal(L, "arg");
421}
422
423static int msghandler(lua_State *L) {
424  const char *msg = lua_tostring(L, 1);
425  if (msg == NULL) {
426    if (luaL_callmeta(L, 1, "__tostring") && lua_type(L, -1) == LUA_TSTRING)
427      return 1;
428    msg = lua_pushfstring(L, "(error object is a %s value)", luaL_typename(L, 1));
429  }
430  lua_getglobal(L, "debug");
431  lua_getfield(L, -1, "traceback");
432  lua_remove(L, -2);
433  lua_pushstring(L, msg);
434  lua_remove(L, -3);
435  lua_pushinteger(L, 2);
436  lua_call(L, 2, 1);
437  return 1;
438}
439
440static int docall(lua_State *L, int narg, int nres) {
441  int status;
442  int base = lua_gettop(L) - narg;
443  lua_pushcfunction(L, msghandler);
444  lua_insert(L, base);
445  globalL = L;
446  signal(SIGINT, laction);
447  status = lua_pcall(L, narg, nres, base);
448  signal(SIGINT, SIG_DFL);
449  lua_remove(L, base);
450  return status;
451}
452
453"#;
454
455const LUA_LOADER_SOURCE: &str = r#"local args = {...}
456local lua_bundle = args[1]
457
458local function load_string(str, name)
459	if _VERSION == "Lua 5.1" then
460		return loadstring(str, name)
461	else
462		return load(str, name)
463	end
464end
465
466local function lua_loader(name)
467	local separator = package.config:sub(1, 1)
468	name = name:gsub(separator, ".")
469	local mod = lua_bundle[name] or lua_bundle[name .. ".init"]
470	if mod then
471		if type(mod) == "string" then
472			local chunk, errstr = load_string(mod, name)
473			if chunk then
474				return chunk
475			else
476				error(
477					("error loading module '%s' from static Lua bundle:\n\t%s"):format(name, errstr),
478					0
479				)
480			end
481		elseif type(mod) == "function" then
482			return mod
483		end
484	else
485		return ("\n\tno module '%s' in static Lua bundle"):format(name)
486	end
487end
488table.insert(package.loaders or package.searchers, 2, lua_loader)
489
490local unpack = unpack or table.unpack
491"#;
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    use assert_fs::fixture::PathCopy;
498    #[cfg(target_os = "linux")]
499    use assert_fs::prelude::{PathChild, PathCreateDir};
500    use assert_fs::TempDir;
501
502    use crate::lua_installation::detect_installed_lua_version;
503    use crate::{config::ConfigBuilder, lua_version::LuaVersion, tree::FlatDistTree};
504    #[cfg(target_os = "linux")]
505    use crate::{
506        fs,
507        lockfile::{LocalPackage, LocalPackageHashes, LockConstraint},
508        package::PackageSpec,
509        remote_package_source::RemotePackageSource,
510        rockspec::RockBinaries,
511    };
512
513    #[cfg(target_os = "linux")]
514    fn mk_dummy_package(spec: PackageSpec) -> LocalPackage {
515        let hashes = LocalPackageHashes {
516            rockspec: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
517                .parse()
518                .unwrap(),
519            source: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
520                .parse()
521                .unwrap(),
522        };
523        LocalPackage::from(
524            &spec,
525            LockConstraint::Unconstrained,
526            RockBinaries::default(),
527            RemotePackageSource::Test,
528            None,
529            hashes,
530        )
531    }
532
533    #[cfg(target_os = "linux")]
534    #[tokio::test]
535    async fn test_collect_installed_files() {
536        let staging = TempDir::new().unwrap();
537        let config = ConfigBuilder::new()
538            .unwrap()
539            .lua_version(Some(LuaVersion::Lua51))
540            .build()
541            .unwrap();
542        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
543
544        let pkg_a = mk_dummy_package(PackageSpec::new("foo".into(), "1.0.0-1".parse().unwrap()));
545        let layout_a = tree.entrypoint(&pkg_a).unwrap();
546        staging
547            .child(layout_a.src.strip_prefix(staging.path()).unwrap())
548            .create_dir_all()
549            .unwrap();
550        fs::tokio::write(layout_a.src.join("foo.lua"), "return {}")
551            .await
552            .unwrap();
553
554        let pkg_b = mk_dummy_package(PackageSpec::new("bar".into(), "2.0.0-1".parse().unwrap()));
555        let layout_b = tree.entrypoint(&pkg_b).unwrap();
556        staging
557            .child(layout_b.src.strip_prefix(staging.path()).unwrap())
558            .create_dir_all()
559            .unwrap();
560        fs::tokio::write(layout_b.src.join("bar.lua"), "return {}")
561            .await
562            .unwrap();
563        staging
564            .child(layout_b.lib.strip_prefix(staging.path()).unwrap())
565            .create_dir_all()
566            .unwrap();
567        fs::tokio::write(
568            layout_b.lib.join(format!("bar.{}", c_dylib_extension())),
569            "",
570        )
571        .await
572        .unwrap();
573
574        {
575            let lockfile = tree.lockfile().unwrap();
576            let mut lockfile = lockfile.write_guard();
577            lockfile.add_entrypoint(&pkg_a);
578            lockfile.add_entrypoint(&pkg_b);
579        }
580
581        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
582        let files = collect_installed_files(&tree).unwrap();
583
584        assert_eq!(files.src.len(), 2);
585        assert!(files
586            .src
587            .iter()
588            .all(|(_, p)| p.extension().is_some_and(|e| e == "lua")));
589        assert_eq!(files.lib.len(), 1);
590        assert!(files.lib[0]
591            .extension()
592            .is_some_and(|e| e == c_dylib_extension()));
593    }
594
595    #[tokio::test]
596    async fn test_collect_installed_files_from_sample_project() {
597        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
598            .join("resources/test/sample-projects/only-src/");
599        let project_dir = TempDir::new().unwrap();
600        project_dir.copy_from(&sample, &["**"]).unwrap();
601
602        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
603        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
604        let config = ConfigBuilder::new()
605            .unwrap()
606            .lua_version(lua_version)
607            .build()
608            .unwrap();
609
610        let staging = TempDir::new().unwrap();
611        let tree = FlatDistTree::new(
612            staging.to_path_buf(),
613            config.lua_version().cloned().unwrap(),
614            &config,
615        )
616        .unwrap();
617
618        InstallProject::new()
619            .project(&project)
620            .config(&config)
621            .tree(&tree)
622            .build()
623            .await
624            .unwrap();
625
626        let files = collect_installed_files(&tree).unwrap();
627
628        let module_keys: Vec<&str> = files.src.iter().map(|(m, _)| m.as_str()).collect();
629
630        assert!(
631            module_keys.contains(&"main"),
632            "expected 'main' in {module_keys:?}"
633        );
634        assert!(
635            module_keys.contains(&"foo"),
636            "expected 'foo' in {module_keys:?}"
637        );
638    }
639
640    #[tokio::test]
641    async fn test_dist_bin_from_lua_source_compiles_and_runs() {
642        test_dist_bin_compiles_and_runs("resources/test/sample-projects/only-src/", "1").await
643    }
644
645    #[tokio::test]
646    #[cfg(target_os = "linux")]
647    async fn test_dist_bin_from_c_source_compiles_and_runs() {
648        test_dist_bin_compiles_and_runs("resources/test/sample-projects/c-src/", "OK").await
649    }
650
651    async fn test_dist_bin_compiles_and_runs(sample_project_path: &str, expected_output: &str) {
652        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(sample_project_path);
653        let project_dir = TempDir::new().unwrap();
654        project_dir.copy_from(&sample, &["**"]).unwrap();
655
656        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
657        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
658        let config = ConfigBuilder::new()
659            .unwrap()
660            .lua_version(lua_version)
661            .build()
662            .unwrap();
663
664        let staging = TempDir::new().unwrap();
665        let tree = FlatDistTree::new(
666            staging.to_path_buf(),
667            config.lua_version().cloned().unwrap(),
668            &config,
669        )
670        .unwrap();
671
672        let out_dir = TempDir::new().unwrap();
673        let binary = out_dir.path().join(if cfg!(target_env = "msvc") {
674            "sample-project.exe"
675        } else {
676            "sample-project"
677        });
678
679        DistProjectBin::new()
680            .project(&project)
681            .config(&config)
682            .tree(&tree)
683            .output(binary.clone())
684            .compile()
685            .await
686            .unwrap();
687
688        assert!(binary.is_file(), "binary not produced");
689
690        let out = tokio::process::Command::new(&binary)
691            .output()
692            .await
693            .unwrap();
694
695        assert!(out.status.success(), "binary exited non-zero:\n{:?}", out);
696        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), expected_output);
697    }
698}