Skip to main content

lux_lib/build/
install.rs

1use std::{
2    path::{Path, PathBuf},
3    process::Stdio,
4    string::FromUtf8Error,
5};
6
7use itertools::Itertools;
8use miette::Diagnostic;
9use path_slash::PathExt;
10use thiserror::Error;
11use tokio::process::Command;
12use walkdir::{DirEntry, WalkDir};
13use which::which;
14
15use crate::{
16    config::Config,
17    fs,
18    lua_installation::LuaInstallation,
19    lua_rockspec::{DeploySpec, LuaModule},
20    path::{self, Paths},
21    tree::InstallTree,
22};
23
24#[derive(Debug, Error, Diagnostic)]
25pub enum Error {
26    #[error(transparent)]
27    #[diagnostic(transparent)]
28    Fs(#[from] fs::Error),
29    #[error(transparent)]
30    #[diagnostic(transparent)]
31    Paths(#[from] path::Error),
32    #[error(transparent)]
33    Utf8(#[from] FromUtf8Error),
34    #[error("no Lua executable found")]
35    NoLuaBinary {
36        #[help]
37        help: String,
38    },
39}
40
41/// Copies a lua source file to a specific destination. The destination is described by a
42/// `module.path` syntax (equivalent to the syntax provided to Lua's `require()` function).
43#[tracing::instrument(
44    name = "Copying Lua module",
45    level = "trace",
46    skip_all,
47    fields(
48        source = source.to_slash_lossy().to_string(),
49        target = target_module.to_string(),
50        target_dir = target_dir.to_slash_lossy().to_string(),
51    ),
52)]
53pub(crate) fn lua_module(
54    source: &PathBuf,
55    target_module: &LuaModule,
56    target_dir: &Path,
57) -> Result<(), fs::Error> {
58    // Some rockspecs abuse `install.lua` to install non-lua files, such as teal (`.tl`) files.
59    let target_module_path = source
60        .extension()
61        .map(|ext| target_module.to_file_path(&format!(".{}", ext.to_string_lossy().as_ref())))
62        .unwrap_or_else(|| target_module.to_lua_path());
63    let target = target_dir.join(target_module_path);
64
65    if let Some(parent) = target.parent() {
66        fs::sync::create_dir_all(parent)?;
67    }
68
69    fs::sync::copy(source, &target)?;
70    fs::make_writable_sync(&target)?;
71
72    Ok(())
73}
74
75/// Returns the file path of the installed binary
76#[tracing::instrument(
77    name = "Installing binary",
78    level = "trace",
79    skip_all,
80    fields(
81        source = source.to_slash_lossy().to_string(),
82        target,
83    ),
84)]
85pub(crate) async fn binary(
86    source: &Path,
87    target: &str,
88    tree: &impl InstallTree,
89    lua: &LuaInstallation,
90    deploy: &DeploySpec,
91    config: &Config,
92) -> Result<PathBuf, Error> where
93{
94    fs::tokio::create_dir_all(&tree.bin()).await?;
95    let paths = Paths::new(tree)?;
96    let script = if config.wrap_bin_scripts()
97        && deploy.wrap_bin_scripts
98        && is_compatible_lua_script(source, lua, &paths, config).await
99    {
100        wrapped_binary(source, target, tree, lua, config).await?
101    } else {
102        let target = tree.bin().join(target);
103        fs::tokio::copy(source, &target).await?;
104        target
105    };
106
107    #[cfg(unix)]
108    fs::set_executable_permissions(&script).await?;
109
110    Ok(script)
111}
112
113#[tracing::instrument("Installing wrapped binary", level = "trace", skip(tree))]
114async fn wrapped_binary(
115    source: &Path,
116    target: &str,
117    tree: &impl InstallTree,
118    lua: &LuaInstallation,
119    config: &Config,
120) -> Result<PathBuf, Error> {
121    let unwrapped_bin_dir = tree.unwrapped_bin();
122    fs::tokio::create_dir_all(&unwrapped_bin_dir).await?;
123    let unwrapped_bin = unwrapped_bin_dir.join(target);
124    fs::tokio::copy(source, &unwrapped_bin).await?;
125    fs::make_writable(&unwrapped_bin).await?;
126
127    #[cfg(target_family = "unix")]
128    let target = tree.bin().join(target);
129    #[cfg(target_family = "windows")]
130    let target = tree.bin().join(format!("{}.bat", target));
131
132    let lua_bin = lua.lua_binary_or_config_override(config).ok_or({
133        let bin_name = config
134            .variables()
135            .get("LUA")
136            .cloned()
137            .or(lua
138                .bin()
139                .as_ref()
140                .map(|bin| bin.to_slash_lossy().to_string()))
141            .unwrap_or("lua".to_string());
142        Error::NoLuaBinary {
143            help: format!(
144                "make sure {bin_name} (version {}) is on the PATH or has been installed by Lux",
145                lua.version
146            ),
147        }
148    })?;
149
150    #[cfg(target_family = "unix")]
151    let content = format!(
152        r#"#!/bin/sh
153
154exec "{0}" "{1}" "$@"
155"#,
156        lua_bin,
157        unwrapped_bin.display(),
158    );
159    #[cfg(target_family = "windows")]
160    let content = format!(
161        r#"@echo off
162setlocal
163
164{0} "{1}" %*
165
166exit /b %ERRORLEVEL%
167"#,
168        lua_bin,
169        unwrapped_bin.display(),
170    );
171
172    fs::tokio::write(&target, content).await?;
173    Ok(target)
174}
175
176/// Tries to load the file with Lua. If the file can be loaded,
177/// we treat it as a valid Lua script.
178///
179/// NOTE: Lua may successfully load very short bash scripts (like `echo "Hello"`).
180/// But that's unlikely to be the case in practise.
181/// If a script is mistaken for a Lua script, package authors can disable
182/// wrapping with the `deploy.wrap_bin_scripts` rockspec config.
183#[tracing::instrument(level = "trace")]
184async fn is_compatible_lua_script(
185    file: &Path,
186    lua: &LuaInstallation,
187    paths: &Paths,
188    config: &Config,
189) -> bool {
190    let lua_path = paths.package_path_prepended().joined();
191    let lua_cpath = paths.package_cpath_prepended().joined();
192    let path = paths.path_prepended().joined();
193    if let Some(lua_bin) = &lua.lua_binary_or_config_override(config) {
194        let lua_bin_path = PathBuf::from(lua_bin);
195        if lua_bin_path.is_file() || which(lua_bin).is_ok() {
196            Command::new(lua_bin)
197                .arg("-e")
198                .arg(format!(
199                    "if loadfile('{}') then os.exit(0) else os.exit(1) end",
200                    // On Windows, Lua escapes path separators, so we ensure forward slashes
201                    file.to_slash_lossy()
202                ))
203                .stderr(Stdio::null())
204                .stdout(Stdio::null())
205                .env("LUA_PATH", lua_path)
206                .env("LUA_CPATH", lua_cpath)
207                .env("PATH", path)
208                .status()
209                .await
210                .is_ok_and(|status| status.success())
211        } else {
212            is_compatible_lua_script_fallback(file, lua, config).await
213        }
214    } else {
215        false
216    }
217}
218
219/// Fallback function for `is_compatible_lua_script`.
220/// Uses the bundled Lua interpreter. This may be less reliable,
221/// as it cannot load scripts for an incompatible Lua version.
222/// But it works for test dependencies like nlua.
223#[tracing::instrument(level = "trace")]
224async fn is_compatible_lua_script_fallback(
225    file: &Path,
226    lua_installation: &LuaInstallation,
227    config: &Config,
228) -> bool {
229    if let Ok(file_content) = fs::tokio::read_to_string(&file).await {
230        let file_content_without_comments = file_content
231            .lines()
232            .filter(|line| !line.trim_start().starts_with("#"))
233            .collect_vec()
234            .join("\n");
235        lua_installation
236            .lua_binary_or_config_override(config)
237            .is_some_and(|_| {
238                // Try to compile the content without executing it
239                ottavino::Lua::core()
240                    .try_enter(|ctx| {
241                        ottavino::Closure::load(
242                            ctx,
243                            None,
244                            file_content_without_comments.as_bytes(),
245                        )?;
246                        Ok(())
247                    })
248                    .is_ok()
249            })
250    } else {
251        false
252    }
253}
254
255#[tracing::instrument(level = "trace")]
256pub(crate) fn detect_binaries(build_dir: &Path) -> Vec<PathBuf> {
257    WalkDir::new(build_dir.join("src").join("bin"))
258        .into_iter()
259        .chain(WalkDir::new(build_dir.join("bin")))
260        .filter_map(|file| file.ok())
261        .filter(|file| file.clone().into_path().is_file())
262        .map(DirEntry::into_path)
263        .collect()
264}
265
266#[cfg(test)]
267mod tests {
268    use tokio::process::Command;
269
270    use crate::{
271        config::ConfigBuilder, lua_installation::detect_installed_lua_version,
272        lua_version::LuaVersion, tree::Tree,
273    };
274
275    use super::*;
276
277    #[tokio::test]
278    async fn test_is_compatible_lua_script() {
279        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
280        let config = ConfigBuilder::new()
281            .unwrap()
282            .lua_version(lua_version)
283            .build()
284            .unwrap();
285        let lua_version = config.lua_version().unwrap();
286        let lua = LuaInstallation::new(lua_version, &config).await.unwrap();
287        let valid_script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
288            .join("resources/test/sample_lua_bin_script_valid");
289        let temp_dir = assert_fs::TempDir::new().unwrap().to_path_buf();
290        let tree = Tree::new(temp_dir, lua_version.clone(), &config).unwrap();
291        let paths = Paths::new(&tree).unwrap();
292        assert!(is_compatible_lua_script(&valid_script, &lua, &paths, &config).await);
293        let invalid_script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
294            .join("resources/test/sample_lua_bin_script_invalid");
295        assert!(!is_compatible_lua_script(&invalid_script, &lua, &paths, &config).await);
296    }
297
298    #[tokio::test]
299    async fn test_wrapped_binary() {
300        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
301        let temp = assert_fs::TempDir::new().unwrap();
302        let config = ConfigBuilder::new()
303            .unwrap()
304            .lua_version(lua_version)
305            .user_tree(Some(temp.to_path_buf()))
306            .build()
307            .unwrap();
308        let lua_version = config.lua_version().unwrap();
309        let lua = LuaInstallation::new(lua_version, &config).await.unwrap();
310        let tree = config.user_tree(lua_version.clone()).unwrap();
311        let valid_script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
312            .join("resources/test/sample_lua_bin_script_valid");
313        let script_name = "test_script";
314        let script_path = wrapped_binary(&valid_script, script_name, &tree, &lua, &config)
315            .await
316            .unwrap();
317
318        #[cfg(unix)]
319        fs::set_executable_permissions(&script_path).await.unwrap();
320
321        assert!(Command::new(script_path.to_string_lossy().to_string())
322            .status()
323            .await
324            .is_ok_and(|status| status.success()));
325    }
326}