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#[derive(Builder)]
29#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
30pub struct DistProjectBin<'a, T>
31where
32 T: InstallTree,
33{
34 project: &'a Project,
36
37 config: &'a Config,
38
39 tree: &'a T,
41
42 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#[derive(Debug)]
97struct InstalledFiles {
98 src: Vec<(LuaModule, PathBuf)>,
100 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 Ok(InstalledFiles {
205 src: lua_sources.into_iter().unique().collect(),
206 lib: native_modules.into_iter().unique().collect(),
207 })
208}
209
210fn 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
218fn 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
230async 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 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 #[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}