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