Skip to main content

lux_lib/operations/
dist_bin.rs

1use std::{
2    io,
3    path::{Path, PathBuf},
4    process::Stdio,
5    sync::Arc,
6};
7
8use bon::Builder;
9use itertools::Itertools;
10use thiserror::Error;
11use walkdir::WalkDir;
12
13use crate::{
14    build::utils::c_dylib_extension,
15    config::Config,
16    lua_installation::{LuaInstallation, LuaInstallationError},
17    lua_rockspec::LuaModule,
18    operations::{InstallProject, InstallProjectError},
19    progress::{MultiProgress, Progress},
20    project::{project_toml::LocalProjectTomlValidationError, Project},
21    rockspec::Rockspec,
22    tree::{InstallTree, TreeError},
23};
24
25/// Compile a Lux project and all its dependencies into a single
26/// static binary that does not require a Lua installation.
27///
28/// Based on [luastatic](https://github.com/ers35/luastatic)
29#[derive(Builder)]
30#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
31pub struct DistProjectBin<'a, T>
32where
33    T: InstallTree,
34{
35    /// The project to compile.
36    project: &'a Project,
37
38    config: &'a Config,
39
40    /// Tree in which to install the project before compiling.
41    tree: &'a T,
42
43    /// Destination path for the compiled binary.
44    /// Defaults to `<cwd>/<package>[.exe]`.
45    output: Option<PathBuf>,
46
47    progress: Option<Arc<Progress<MultiProgress>>>,
48}
49
50#[derive(Error, Debug)]
51pub enum DistProjectBinError {
52    #[error("error installing project:\n{0}")]
53    InstallProject(#[from] InstallProjectError),
54    #[error(transparent)]
55    LocalProjectTomlValidation(#[from] LocalProjectTomlValidationError),
56    #[error(transparent)]
57    Tree(#[from] TreeError),
58    #[cfg(not(target_os = "linux"))]
59    #[error(
60        r#"Lua binary libraries are only linkable on Linux.
61Cannot link the following binaries:
62{0}"#
63    )]
64    CannotLinkBinaryLibs(String),
65    #[error(transparent)]
66    LuaInstallation(#[from] LuaInstallationError),
67    #[error(transparent)]
68    CC(#[from] cc::Error),
69    #[error(transparent)]
70    Io(#[from] io::Error),
71    #[error("C compilation failed (exit {status}):\nstdout: {stdout}\nstderr: {stderr}")]
72    CompilationFailed {
73        status: std::process::ExitStatus,
74        stdout: String,
75        stderr: String,
76    },
77}
78
79impl<T, State> DistProjectBinBuilder<'_, T, State>
80where
81    T: InstallTree + Sync + Send + Clone + 'static,
82    State: dist_project_bin_builder::State + dist_project_bin_builder::IsComplete,
83{
84    pub async fn compile(self) -> Result<PathBuf, DistProjectBinError> {
85        do_dist_project_bin(self._build()).await
86    }
87}
88
89/// Files collected from an installed tree for binary compilation.
90#[derive(Debug)]
91struct InstalledFiles {
92    /// Lua source files (.lua) to embed.
93    src: Vec<(LuaModule, PathBuf)>,
94    /// Native Lua modules (.so/.dll/.dylib) to link.
95    lib: Vec<PathBuf>,
96}
97
98async fn do_dist_project_bin<T>(args: DistProjectBin<'_, T>) -> Result<PathBuf, DistProjectBinError>
99where
100    T: InstallTree + Sync + Send + Clone + 'static,
101{
102    let progress = args
103        .progress
104        .clone()
105        .unwrap_or_else(|| MultiProgress::new_arc(args.config));
106
107    let package = InstallProject::new()
108        .project(args.project)
109        .config(args.config)
110        .tree(args.tree)
111        .maybe_progress(args.progress)
112        .build()
113        .await?;
114
115    let files = collect_installed_files(args.tree)?;
116
117    let project_toml = args.project.toml().into_local()?;
118    let pkg_name = project_toml.package().to_string();
119
120    let entrypoint_module = project_toml
121        .run()
122        .and_then(|r| r.current_platform().args.as_ref())
123        .map(|args| args.first().as_str())
124        .map(entrypoint_stem)
125        .unwrap_or_else(|| pkg_name.clone());
126
127    let layout = args.tree.installed_rock_layout(&package)?;
128
129    let lua = LuaInstallation::new_from_config(args.config, &progress.map(|p| p.new_bar())).await?;
130
131    let output = args.output.unwrap_or_else(|| {
132        let mut p = PathBuf::from(&pkg_name);
133        if cfg!(target_env = "msvc") {
134            p.set_extension("exe");
135        }
136        p
137    });
138
139    let lib_root = layout.lib.clone();
140    let c_src = generate_c_source(&entrypoint_module, &files, &lib_root).await?;
141
142    let work_dir = tempfile::tempdir()?;
143    let c_path = work_dir.path().join(format!("{pkg_name}.static.c"));
144    tokio::fs::write(&c_path, &c_src).await?;
145
146    compile_binary(&c_path, &output, &lua, &files.lib, &work_dir, args.config).await?;
147
148    Ok(output)
149}
150
151#[allow(clippy::result_large_err)]
152fn collect_installed_files(tree: &impl InstallTree) -> Result<InstalledFiles, DistProjectBinError> {
153    let mut lua_sources = Vec::new();
154    let mut native_modules = Vec::new();
155    let c_dylib_ext = c_dylib_extension();
156
157    for package in tree.list()?.values().flatten() {
158        let layout = tree.installed_rock_layout(package)?;
159
160        if layout.src.is_dir() {
161            let src_canonical = layout.src.canonicalize().unwrap_or(layout.src.clone());
162            for path in WalkDir::new(&src_canonical)
163                .into_iter()
164                .filter_map(|e| e.ok())
165                .map(|e| e.into_path())
166                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "lua"))
167            {
168                let rel = path
169                    .strip_prefix(&src_canonical)
170                    .unwrap_or(&path)
171                    .with_extension("");
172                if let Ok(module) = LuaModule::from_pathbuf(rel) {
173                    lua_sources.push((module, path));
174                }
175            }
176        }
177
178        if layout.lib.is_dir() {
179            let lib_canononical = layout.lib.canonicalize().unwrap_or(layout.lib.clone());
180            for path in WalkDir::new(&lib_canononical)
181                .into_iter()
182                .filter_map(|e| e.ok())
183                .map(|e| e.into_path())
184                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == c_dylib_ext))
185            {
186                native_modules.push(path);
187            }
188        }
189    }
190    #[cfg(not(target_os = "linux"))]
191    if !native_modules.is_empty() {
192        return Err(DistProjectBinError::CannotLinkBinaryLibs(
193            native_modules
194                .iter()
195                .unique()
196                .map(|p| p.to_string_lossy())
197                .join("\n"),
198        ));
199    }
200
201    // NOTE: A `FlatDistTree` can produce duplicates, as all modules share the same `src`.
202    Ok(InstalledFiles {
203        src: lua_sources.into_iter().unique().collect(),
204        lib: native_modules.into_iter().unique().collect(),
205    })
206}
207
208/// Derive a module stem from a run-spec arg like `"src/main.lua"` -> `"main"`.
209fn entrypoint_stem(arg: &str) -> String {
210    PathBuf::from(arg)
211        .file_stem()
212        .map(|s| s.to_string_lossy().into_owned())
213        .unwrap_or_else(|| arg.to_owned())
214}
215
216/// Derive the Lua module name from an installed lib path.
217/// e.g. `<tree>/lib/foo/bar.so` -> `("foo.bar", "foo_bar")`.
218fn module_names(lib_root: &Path, path: &Path) -> (String, String) {
219    let rel = path.strip_prefix(lib_root).unwrap_or(path);
220    let dotpath = rel
221        .with_extension("")
222        .to_string_lossy()
223        .replace(std::path::MAIN_SEPARATOR, ".");
224    let underscore = dotpath.replace(['.', '-'], "_");
225    (dotpath, underscore)
226}
227
228/// Generate a C source file embedding all Lua sources.
229async fn generate_c_source(
230    entrypoint_module: &str,
231    files: &InstalledFiles,
232    lib_root: &Path,
233) -> Result<String, io::Error> {
234    let mut out = String::from(C_PREAMBLE);
235
236    out.push_str("#ifdef __cplusplus\nextern \"C\" {\n#endif\n");
237    for path in &files.lib {
238        let (_, underscore) = module_names(lib_root, path);
239        out.push_str(&format!("int luaopen_{underscore}(lua_State *L);\n"));
240    }
241    out.push_str("#ifdef __cplusplus\n}\n#endif\n\n");
242
243    for (i, (_, path)) in files.src.iter().enumerate() {
244        let bytes = tokio::fs::read(path).await.map_err(|err| {
245            io::Error::other(format!("unable to read '{}': {err}", path.display()))
246        })?;
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 = 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        lockfile::{LocalPackage, LocalPackageHashes, LockConstraint},
507        package::PackageSpec,
508        remote_package_source::RemotePackageSource,
509        rockspec::RockBinaries,
510    };
511
512    #[cfg(target_os = "linux")]
513    fn mk_dummy_package(spec: PackageSpec) -> LocalPackage {
514        let hashes = LocalPackageHashes {
515            rockspec: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
516                .parse()
517                .unwrap(),
518            source: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
519                .parse()
520                .unwrap(),
521        };
522        LocalPackage::from(
523            &spec,
524            LockConstraint::Unconstrained,
525            RockBinaries::default(),
526            RemotePackageSource::Test,
527            None,
528            hashes,
529        )
530    }
531
532    #[cfg(target_os = "linux")]
533    #[tokio::test]
534    async fn test_collect_installed_files() {
535        let staging = TempDir::new().unwrap();
536        let config = ConfigBuilder::new()
537            .unwrap()
538            .lua_version(Some(LuaVersion::Lua51))
539            .build()
540            .unwrap();
541        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
542
543        let pkg_a = mk_dummy_package(PackageSpec::new("foo".into(), "1.0.0-1".parse().unwrap()));
544        let layout_a = tree.entrypoint(&pkg_a).unwrap();
545        staging
546            .child(layout_a.src.strip_prefix(staging.path()).unwrap())
547            .create_dir_all()
548            .unwrap();
549        tokio::fs::write(layout_a.src.join("foo.lua"), "return {}")
550            .await
551            .unwrap();
552
553        let pkg_b = mk_dummy_package(PackageSpec::new("bar".into(), "2.0.0-1".parse().unwrap()));
554        let layout_b = tree.entrypoint(&pkg_b).unwrap();
555        staging
556            .child(layout_b.src.strip_prefix(staging.path()).unwrap())
557            .create_dir_all()
558            .unwrap();
559        tokio::fs::write(layout_b.src.join("bar.lua"), "return {}")
560            .await
561            .unwrap();
562        staging
563            .child(layout_b.lib.strip_prefix(staging.path()).unwrap())
564            .create_dir_all()
565            .unwrap();
566        tokio::fs::write(
567            layout_b.lib.join(format!("bar.{}", c_dylib_extension())),
568            "",
569        )
570        .await
571        .unwrap();
572
573        {
574            let lockfile = tree.lockfile().unwrap();
575            let mut lockfile = lockfile.write_guard();
576            lockfile.add_entrypoint(&pkg_a);
577            lockfile.add_entrypoint(&pkg_b);
578        }
579
580        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
581        let files = collect_installed_files(&tree).unwrap();
582
583        assert_eq!(files.src.len(), 2);
584        assert!(files
585            .src
586            .iter()
587            .all(|(_, p)| p.extension().is_some_and(|e| e == "lua")));
588        assert_eq!(files.lib.len(), 1);
589        assert!(files.lib[0]
590            .extension()
591            .is_some_and(|e| e == c_dylib_extension()));
592    }
593
594    #[tokio::test]
595    async fn test_collect_installed_files_from_sample_project() {
596        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
597            .join("resources/test/sample-projects/only-src/");
598        let project_dir = TempDir::new().unwrap();
599        project_dir.copy_from(&sample, &["**"]).unwrap();
600
601        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
602        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
603        let config = ConfigBuilder::new()
604            .unwrap()
605            .lua_version(lua_version)
606            .build()
607            .unwrap();
608
609        let staging = TempDir::new().unwrap();
610        let tree = FlatDistTree::new(
611            staging.to_path_buf(),
612            config.lua_version().cloned().unwrap(),
613            &config,
614        )
615        .unwrap();
616
617        InstallProject::new()
618            .project(&project)
619            .config(&config)
620            .tree(&tree)
621            .build()
622            .await
623            .unwrap();
624
625        let files = collect_installed_files(&tree).unwrap();
626
627        let module_keys: Vec<&str> = files.src.iter().map(|(m, _)| m.as_str()).collect();
628
629        assert!(
630            module_keys.contains(&"main"),
631            "expected 'main' in {module_keys:?}"
632        );
633        assert!(
634            module_keys.contains(&"foo"),
635            "expected 'foo' in {module_keys:?}"
636        );
637    }
638
639    #[tokio::test]
640    async fn test_dist_bin_from_lua_source_compiles_and_runs() {
641        test_dist_bin_compiles_and_runs("resources/test/sample-projects/only-src/", "1").await
642    }
643
644    #[tokio::test]
645    #[cfg(target_os = "linux")]
646    async fn test_dist_bin_from_c_source_compiles_and_runs() {
647        test_dist_bin_compiles_and_runs("resources/test/sample-projects/c-src/", "OK").await
648    }
649
650    async fn test_dist_bin_compiles_and_runs(sample_project_path: &str, expected_output: &str) {
651        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(sample_project_path);
652        let project_dir = TempDir::new().unwrap();
653        project_dir.copy_from(&sample, &["**"]).unwrap();
654
655        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
656        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
657        let config = ConfigBuilder::new()
658            .unwrap()
659            .lua_version(lua_version)
660            .build()
661            .unwrap();
662
663        let staging = TempDir::new().unwrap();
664        let tree = FlatDistTree::new(
665            staging.to_path_buf(),
666            config.lua_version().cloned().unwrap(),
667            &config,
668        )
669        .unwrap();
670
671        let out_dir = TempDir::new().unwrap();
672        let binary = out_dir.path().join(if cfg!(target_env = "msvc") {
673            "sample-project.exe"
674        } else {
675            "sample-project"
676        });
677
678        DistProjectBin::new()
679            .project(&project)
680            .config(&config)
681            .tree(&tree)
682            .output(binary.clone())
683            .compile()
684            .await
685            .unwrap();
686
687        assert!(binary.is_file(), "binary not produced");
688
689        let out = tokio::process::Command::new(&binary)
690            .output()
691            .await
692            .unwrap();
693
694        assert!(out.status.success(), "binary exited non-zero:\n{:?}", out);
695        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), expected_output);
696    }
697}