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#[derive(Builder)]
28#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
29pub struct DistProjectBin<'a, T>
30where
31 T: InstallTree,
32{
33 project: &'a Project,
35
36 config: &'a Config,
37
38 tree: &'a T,
40
41 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#[derive(Debug)]
92struct InstalledFiles {
93 src: Vec<(LuaModule, PathBuf)>,
95 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 Ok(InstalledFiles {
200 src: lua_sources.into_iter().unique().collect(),
201 lib: native_modules.into_iter().unique().collect(),
202 })
203}
204
205fn 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
213fn 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
225async 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 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 #[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}