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