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#[derive(Builder)]
30#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
31pub struct DistProjectBin<'a, T>
32where
33 T: InstallTree,
34{
35 project: &'a Project,
37
38 config: &'a Config,
39
40 tree: &'a T,
42
43 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#[derive(Debug)]
91struct InstalledFiles {
92 src: Vec<(LuaModule, PathBuf)>,
94 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 Ok(InstalledFiles {
203 src: lua_sources.into_iter().unique().collect(),
204 lib: native_modules.into_iter().unique().collect(),
205 })
206}
207
208fn 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
216fn 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
228async 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 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 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}