Skip to main content

lux_lib/lua_installation/
mod.rs

1use is_executable::IsExecutable;
2use itertools::Itertools;
3use miette::Diagnostic;
4use path_slash::PathBufExt;
5use std::fmt;
6use std::fmt::Display;
7use std::io;
8use std::path::Path;
9use std::path::PathBuf;
10use thiserror::Error;
11use which::which;
12
13use crate::build::external_dependency::to_lib_name;
14use crate::build::external_dependency::ExternalDependencyInfo;
15use crate::build::utils::{c_lib_extension, format_path};
16use crate::config::external_deps::ExternalDependencySearchConfig;
17use crate::fs;
18use crate::lua_rockspec::ExternalDependencySpec;
19use crate::lua_version::LuaVersion;
20use crate::lua_version::LuaVersionUnset;
21use crate::operations;
22use crate::operations::BuildLuaError;
23use crate::tree::InstallTree;
24use crate::variables::GetVariableError;
25use crate::{config::Config, package::PackageVersion, variables::HasVariables};
26use lazy_static::lazy_static;
27use tokio::sync::Mutex;
28
29// Because installing lua is not thread-safe, we have to synchronize with a global Mutex
30lazy_static! {
31    static ref NEW_MUTEX: Mutex<i32> = Mutex::new(0i32);
32    static ref INSTALL_MUTEX: Mutex<i32> = Mutex::new(0i32);
33}
34
35#[derive(Debug)]
36pub struct LuaInstallation {
37    pub version: LuaVersion,
38    dependency_info: ExternalDependencyInfo,
39    /// Binary to the Lua executable, if present
40    pub(crate) bin: Option<PathBuf>,
41}
42
43#[derive(Debug, Error, Diagnostic)]
44pub enum LuaBinaryError {
45    #[error("neither `lua` nor `luajit` found on the PATH")]
46    LuaBinaryNotFound,
47    #[error(transparent)]
48    #[diagnostic(transparent)]
49    DetectLuaVersion(#[from] DetectLuaVersionError),
50    #[error(
51        r#"
52{} -v (= {}) does not match expected Lua version: {}.
53
54Try setting
55
56```toml
57[variables]
58LUA = "/path/to/lua_binary"
59```
60
61in your config, or use `-v LUA=/path/to/lua_binary`.
62    "#,
63        lua_cmd,
64        installed_version,
65        lua_version
66    )]
67    LuaVersionMismatch {
68        lua_cmd: String,
69        installed_version: PackageVersion,
70        lua_version: LuaVersion,
71    },
72    #[error("{0} not found on the PATH")]
73    CustomBinaryNotFound(String),
74}
75
76#[derive(Error, Debug, Diagnostic)]
77#[non_exhaustive]
78pub enum DetectLuaVersionError {
79    #[error("failed to run {0}: {1}")]
80    RunLuaCommand(String, io::Error),
81    #[error("failed to parse Lua version from output: {0}")]
82    ParseLuaVersion(String),
83    #[error(transparent)]
84    #[diagnostic(transparent)]
85    PackageVersionParse(#[from] crate::package::PackageVersionParseError),
86    #[error(transparent)]
87    #[diagnostic(transparent)]
88    LuaVersion(#[from] crate::lua_version::LuaVersionError),
89}
90
91#[derive(Error, Debug, Diagnostic)]
92#[non_exhaustive]
93pub enum LuaInstallationError {
94    #[error("could not find a Lua installation and failed to build Lua from source")]
95    #[diagnostic(forward(0))]
96    Build(#[from] BuildLuaError),
97    #[error(transparent)]
98    #[diagnostic(transparent)]
99    LuaVersionUnset(#[from] LuaVersionUnset),
100}
101
102impl LuaInstallation {
103    #[tracing::instrument(level = "trace", skip_all)]
104    pub async fn new_from_config(config: &Config) -> Result<Self, LuaInstallationError> {
105        Self::new(LuaVersion::from(config)?, config).await
106    }
107
108    #[tracing::instrument(level = "trace", skip(config))]
109    pub async fn new(version: &LuaVersion, config: &Config) -> Result<Self, LuaInstallationError> {
110        let _lock = NEW_MUTEX.lock().await;
111        if let Some(lua_intallation) = Self::probe(version, config.external_deps()) {
112            return Ok(lua_intallation);
113        }
114        let output = Self::root_dir(version, config);
115        let include_dir = output.join("include");
116        let lib_dir = output.join("lib");
117        let lua_lib_name = get_lua_lib_name(&lib_dir, version);
118        if include_dir.is_dir() && lua_lib_name.is_some() {
119            let bin_dir = Some(output.join("bin")).filter(|bin_path| bin_path.is_dir());
120            let bin = bin_dir
121                .as_ref()
122                .and_then(|bin_path| find_lua_executable(bin_path, version));
123            let lib_dir = output.join("lib");
124            let lua_lib_name = get_lua_lib_name(&lib_dir, version);
125            let include_dir = Some(output.join("include"));
126            Ok(LuaInstallation {
127                version: version.clone(),
128                dependency_info: ExternalDependencyInfo {
129                    include_dir,
130                    lib_dir: Some(lib_dir),
131                    bin_dir,
132                    lib_info: None,
133                    lib_name: lua_lib_name,
134                },
135                bin,
136            })
137        } else {
138            Self::install(version, config).await
139        }
140    }
141
142    #[tracing::instrument(level = "trace")]
143    pub(crate) fn probe(
144        version: &LuaVersion,
145        search_config: &ExternalDependencySearchConfig,
146    ) -> Option<Self> {
147        let pkg_name_probes = match version {
148            LuaVersion::Lua51 => vec!["lua5.1", "lua-5.1"],
149            LuaVersion::Lua52 => vec!["lua5.2", "lua-5.2"],
150            LuaVersion::Lua53 => vec!["lua5.3", "lua-5.3"],
151            LuaVersion::Lua54 => vec!["lua5.4", "lua-5.4"],
152            LuaVersion::Lua55 => vec!["lua5.5", "lua-5.5"],
153            LuaVersion::LuaJIT | LuaVersion::LuaJIT52 => vec!["luajit"],
154        };
155
156        let mut dependency_info = pkg_name_probes
157            .iter()
158            .map(|pkg_name| {
159                ExternalDependencyInfo::probe(
160                    pkg_name,
161                    &ExternalDependencySpec::default(),
162                    search_config,
163                )
164            })
165            .find_map(Result::ok);
166
167        if let Some(info) = &mut dependency_info {
168            if info.include_dir.is_none() || info.lib_dir.is_none() {
169                return None;
170            }
171            let bin = info.lib_dir.as_ref().and_then(|lib_dir| {
172                lib_dir
173                    .parent()
174                    .map(|parent| parent.join("bin"))
175                    .filter(|dir| dir.is_dir())
176                    .and_then(|bin_path| find_lua_executable(&bin_path, version))
177            });
178            let lua_lib_name = info
179                .lib_dir
180                .as_ref()
181                .and_then(|lib_dir| get_lua_lib_name(lib_dir, version));
182            info.lib_name = lua_lib_name;
183            dependency_info.map(|dependency_info| Self {
184                version: version.clone(),
185                dependency_info,
186                bin,
187            })
188        } else {
189            None
190        }
191    }
192
193    #[tracing::instrument(level = "trace", skip(config))]
194    pub async fn install(
195        version: &LuaVersion,
196        config: &Config,
197    ) -> Result<Self, LuaInstallationError> {
198        let _lock = INSTALL_MUTEX.lock().await;
199
200        let target = Self::root_dir(version, config);
201
202        operations::BuildLua::new()
203            .lua_version(version)
204            .install_dir(&target)
205            .config(config)
206            .build()
207            .await?;
208
209        let include_dir = target.join("include");
210        let lib_dir = target.join("lib");
211        let bin_dir = Some(target.join("bin")).filter(|bin_path| bin_path.is_dir());
212        let bin = bin_dir
213            .as_ref()
214            .and_then(|bin_path| find_lua_executable(bin_path, version));
215        let lua_lib_name = get_lua_lib_name(&lib_dir, version);
216        Ok(LuaInstallation {
217            version: version.clone(),
218            dependency_info: ExternalDependencyInfo {
219                include_dir: Some(include_dir),
220                lib_dir: Some(lib_dir),
221                bin_dir,
222                lib_info: None,
223                lib_name: lua_lib_name,
224            },
225            bin,
226        })
227    }
228
229    pub fn includes(&self) -> Vec<&PathBuf> {
230        self.dependency_info.include_dir.iter().collect_vec()
231    }
232
233    pub fn bin(&self) -> &Option<PathBuf> {
234        &self.bin
235    }
236
237    fn root_dir(version: &LuaVersion, config: &Config) -> PathBuf {
238        if let Some(lua_dir) = config.lua_dir() {
239            return lua_dir.clone();
240        } else if let Ok(tree) = config.user_tree(version.clone()) {
241            return tree.root().join(".lua");
242        }
243        config.data_dir().join(".lua").join(version.to_string())
244    }
245
246    #[cfg(not(target_env = "msvc"))]
247    fn lua_lib(&self) -> Option<String> {
248        self.dependency_info
249            .lib_name
250            .as_ref()
251            .map(|name| format!("{}.{}", name, c_lib_extension()))
252    }
253
254    #[cfg(target_env = "msvc")]
255    fn lua_lib(&self) -> Option<String> {
256        self.dependency_info.lib_name.clone()
257    }
258
259    pub(crate) fn define_flags(&self) -> Vec<String> {
260        let mut defines = self.dependency_info.define_flags();
261        if self.version.is_luajit() {
262            defines.push("-DLUAJIT_VERSION".into());
263        }
264        defines
265    }
266
267    pub(crate) fn lib_link_args(&self, compiler: &cc::Tool) -> Vec<String> {
268        self.dependency_info.lib_link_args(compiler)
269    }
270
271    /// Get the Lua binary (if present), prioritising
272    /// a potentially overridden value in the config.
273    pub(crate) fn lua_binary_or_config_override(&self, config: &Config) -> Option<String> {
274        config.variables().get("LUA").cloned().or(self
275            .bin
276            .clone()
277            .or(LuaBinary::new(self.version.clone(), config).try_into().ok())
278            .map(|bin| bin.to_slash_lossy().to_string()))
279    }
280}
281
282impl HasVariables for LuaInstallation {
283    #[tracing::instrument(level = "trace")]
284    fn get_variable(&self, input: &str) -> Result<Option<String>, GetVariableError> {
285        Ok(match input {
286            "LUA_INCDIR" => self
287                .dependency_info
288                .include_dir
289                .as_ref()
290                .map(|dir| format_path(dir)),
291            "LUA_LIBDIR" => self
292                .dependency_info
293                .lib_dir
294                .as_ref()
295                .map(|dir| format_path(dir)),
296            "LUA_BINDIR" => self
297                .bin
298                .as_ref()
299                .and_then(|bin| bin.parent().map(format_path)),
300            "LUA" => self
301                .bin
302                .clone()
303                .or(LuaBinary::Lua {
304                    lua_version: self.version.clone(),
305                }
306                .try_into()
307                .ok())
308                .map(|lua| format_path(&lua)),
309            "LUALIB" => self.lua_lib().or(Some("".into())),
310            _ => None,
311        })
312    }
313}
314
315#[derive(Clone)]
316pub enum LuaBinary {
317    /// The regular Lua interpreter.
318    Lua { lua_version: LuaVersion },
319    /// Custom Lua interpreter.
320    Custom(String),
321}
322
323impl Display for LuaBinary {
324    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
325        match self {
326            LuaBinary::Lua { lua_version } => write!(f, "lua {lua_version}"),
327            LuaBinary::Custom(cmd) => write!(f, "{cmd}"),
328        }
329    }
330}
331
332impl LuaBinary {
333    /// Construct a new `LuaBinary` for the given `LuaVersion`,
334    /// potentially prioritising an overridden value in the config.
335    pub fn new(lua_version: LuaVersion, config: &Config) -> Self {
336        match config.variables().get("LUA").cloned() {
337            Some(lua) => Self::Custom(lua),
338            None => Self::Lua { lua_version },
339        }
340    }
341}
342
343impl From<PathBuf> for LuaBinary {
344    fn from(value: PathBuf) -> Self {
345        Self::Custom(value.to_string_lossy().to_string())
346    }
347}
348
349impl TryFrom<LuaBinary> for PathBuf {
350    type Error = LuaBinaryError;
351
352    fn try_from(value: LuaBinary) -> Result<Self, Self::Error> {
353        match value {
354            LuaBinary::Lua { lua_version } => {
355                if let Some(lua_binary) =
356                    LuaInstallation::probe(&lua_version, &ExternalDependencySearchConfig::default())
357                        .and_then(|lua_installation| lua_installation.bin)
358                {
359                    return Ok(lua_binary);
360                }
361                if lua_version.is_luajit() {
362                    if let Ok(path) = which("luajit") {
363                        return Ok(path);
364                    }
365                }
366                match which(format!("lua{}", lua_version)) {
367                    Ok(path) => Ok(path),
368                    Err(_) => detect_default_lua_bin(lua_version),
369                }
370            }
371            LuaBinary::Custom(bin) => match which(&bin) {
372                Ok(path) => Ok(path),
373                Err(_) => Err(LuaBinaryError::CustomBinaryNotFound(bin)),
374            },
375        }
376    }
377}
378
379fn detect_default_lua_bin(lua_version: LuaVersion) -> Result<PathBuf, LuaBinaryError> {
380    match which("lua") {
381        Ok(path) => {
382            let installed_version = detect_installed_lua_version_from_path(&path)?;
383            if lua_version
384                .clone()
385                .as_version_req()
386                .matches(&installed_version)
387            {
388                Ok(path)
389            } else {
390                Err(LuaBinaryError::LuaVersionMismatch {
391                    lua_cmd: path.to_slash_lossy().to_string(),
392                    installed_version,
393                    lua_version,
394                })?
395            }
396        }
397        Err(_) => Err(LuaBinaryError::LuaBinaryNotFound),
398    }
399}
400
401pub fn detect_installed_lua_version() -> Option<LuaVersion> {
402    which("lua")
403        .ok()
404        .or(which("luajit").ok())
405        .and_then(|lua_cmd| {
406            detect_installed_lua_version_from_path(&lua_cmd)
407                .ok()
408                .and_then(|version| LuaVersion::from_version(version).ok())
409        })
410}
411
412/// Determine the Lua version to use with the `nvim` preset.
413/// Prefers LuaJIT if installed, falling back to Lua 5.1.
414pub fn nvim_lua_version() -> Option<LuaVersion> {
415    if LuaInstallation::probe(&LuaVersion::LuaJIT, &Default::default()).is_some() {
416        Some(LuaVersion::LuaJIT)
417    } else if LuaInstallation::probe(&LuaVersion::Lua51, &Default::default()).is_some() {
418        Some(LuaVersion::Lua51)
419    } else {
420        None
421    }
422}
423
424fn find_lua_executable(bin_path: &Path, version: &LuaVersion) -> Option<PathBuf> {
425    fs::sync::read_dir(bin_path).ok().and_then(|entries| {
426        let bin_files = entries
427            .filter_map(Result::ok)
428            .map(|entry| entry.path().to_path_buf())
429            .collect_vec();
430
431        #[cfg(windows)]
432        let ext = ".exe";
433
434        #[cfg(not(windows))]
435        let ext = "";
436
437        // Prioritise Lua binaries with version suffix
438        // (see https://github.com/lumen-oss/lux/issues/1215)
439        let lua_version_bin = format!("lua{}{}", version, ext);
440        if let Some(lua_bin) = bin_files
441            .iter()
442            .filter(|file| {
443                file.is_executable()
444                    && file
445                        .file_name()
446                        .is_some_and(|name| name.to_string_lossy() == lua_version_bin)
447            })
448            .collect_vec()
449            .first()
450            .cloned()
451        {
452            Some(lua_bin.clone())
453        } else {
454            // Fall back to Lua binary without version suffix
455            bin_files
456                .into_iter()
457                .filter(|file| {
458                    file.is_executable()
459                        && file.file_name().is_some_and(|name| {
460                            matches!(
461                                name.to_string_lossy().to_string().as_str(),
462                                "lua" | "luajit" | "lua.exe" | "luajit.exe"
463                            )
464                        })
465                })
466                .collect_vec()
467                .first()
468                .cloned()
469        }
470    })
471}
472
473fn is_lua_lib_name(name: &str, lua_version: &LuaVersion) -> bool {
474    let prefixes = match lua_version {
475        LuaVersion::LuaJIT | LuaVersion::LuaJIT52 => vec!["luajit", "lua"],
476        _ => vec!["lua"],
477    };
478    let version_str = lua_version.version_compatibility_str();
479    let version_suffix = version_str.replace(".", "");
480    #[cfg(target_family = "unix")]
481    let name = name.trim_start_matches("lib");
482    prefixes
483        .iter()
484        .any(|prefix| name == format!("{}.{}", *prefix, c_lib_extension()))
485        || prefixes.iter().any(|prefix| name.starts_with(*prefix))
486            && (name.contains(&version_str) || name.contains(&version_suffix))
487}
488
489fn get_lua_lib_name(lib_dir: &Path, lua_version: &LuaVersion) -> Option<String> {
490    fs::sync::read_dir(lib_dir)
491        .ok()
492        .and_then(|entries| {
493            entries
494                .filter_map(Result::ok)
495                .map(|entry| entry.path().to_path_buf())
496                .filter(|file| file.extension().is_some_and(|ext| ext == c_lib_extension()))
497                .filter(|file| {
498                    file.file_name()
499                        .is_some_and(|name| is_lua_lib_name(&name.to_string_lossy(), lua_version))
500                })
501                .collect_vec()
502                .first()
503                .cloned()
504        })
505        .map(|file| to_lib_name(&file))
506}
507
508fn detect_installed_lua_version_from_path(
509    lua_cmd: &Path,
510) -> Result<PackageVersion, DetectLuaVersionError> {
511    let output = match std::process::Command::new(lua_cmd).arg("-v").output() {
512        Ok(output) => Ok(output),
513        Err(err) => Err(DetectLuaVersionError::RunLuaCommand(
514            lua_cmd.to_string_lossy().to_string(),
515            err,
516        )),
517    }?;
518    let output_vec = if output.stderr.is_empty() {
519        output.stdout
520    } else {
521        // Yes, Lua 5.1 prints to stderr (-‸ლ)
522        output.stderr
523    };
524    let lua_output = String::from_utf8_lossy(&output_vec).to_string();
525    parse_lua_version_from_output(&lua_output)
526}
527
528fn parse_lua_version_from_output(
529    lua_output: &str,
530) -> Result<PackageVersion, DetectLuaVersionError> {
531    let lua_version_str = lua_output
532        .trim_start_matches("Lua")
533        .trim_start_matches("JIT")
534        .split_whitespace()
535        .next()
536        .map(|s| s.to_string())
537        .ok_or(DetectLuaVersionError::ParseLuaVersion(
538            lua_output.to_string(),
539        ))?;
540    Ok(PackageVersion::parse(&lua_version_str)?)
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    #[cfg(feature = "impure_tests")]
547    use flaky_test::flaky_test;
548
549    #[tokio::test]
550    async fn parse_luajit_version() {
551        let luajit_output =
552            "LuaJIT 2.1.1713773202 -- Copyright (C) 2005-2023 Mike Pall. https://luajit.org/";
553        parse_lua_version_from_output(luajit_output).unwrap();
554    }
555
556    #[tokio::test]
557    async fn parse_lua_51_version() {
558        let lua_output = "Lua 5.1.5  Copyright (C) 1994-2012 Lua.org, PUC-Rio";
559        parse_lua_version_from_output(lua_output).unwrap();
560    }
561
562    #[cfg(feature = "impure_tests")]
563    #[flaky_test(tokio, times = 5)]
564    async fn lua_installation_bin() {
565        let config = crate::config::ConfigBuilder::new()
566            .unwrap()
567            .build()
568            .unwrap();
569        let lua_version = config.lua_version().unwrap();
570        let lua_installation = LuaInstallation::new(lua_version, &config).await.unwrap();
571        // FIXME: This fails when run in the nix checkPhase
572        assert!(lua_installation.bin.is_some());
573        let lua_binary: LuaBinary = lua_installation.bin.unwrap().into();
574        let lua_bin_path: PathBuf = lua_binary.try_into().unwrap();
575        let pkg_version = detect_installed_lua_version_from_path(&lua_bin_path).unwrap();
576        assert_eq!(&LuaVersion::from_version(pkg_version).unwrap(), lua_version);
577    }
578
579    #[cfg(not(target_env = "msvc"))]
580    #[tokio::test]
581    async fn test_is_lua_lib_name() {
582        assert!(is_lua_lib_name("lua.a", &LuaVersion::Lua51));
583        assert!(is_lua_lib_name("lua-5.1.a", &LuaVersion::Lua51));
584        assert!(is_lua_lib_name("lua5.1.a", &LuaVersion::Lua51));
585        assert!(is_lua_lib_name("lua51.a", &LuaVersion::Lua51));
586        assert!(!is_lua_lib_name("lua-5.2.a", &LuaVersion::Lua51));
587        assert!(is_lua_lib_name("luajit-5.2.a", &LuaVersion::LuaJIT52));
588        assert!(is_lua_lib_name("lua-5.2.a", &LuaVersion::LuaJIT52));
589        assert!(is_lua_lib_name("liblua.a", &LuaVersion::Lua51));
590        assert!(is_lua_lib_name("liblua-5.1.a", &LuaVersion::Lua51));
591        assert!(is_lua_lib_name("liblua53.a", &LuaVersion::Lua53));
592        assert!(is_lua_lib_name("liblua-54.a", &LuaVersion::Lua54));
593        assert!(is_lua_lib_name("liblua-55.a", &LuaVersion::Lua55));
594    }
595
596    #[cfg(target_env = "msvc")]
597    #[tokio::test]
598    async fn test_is_lua_lib_name() {
599        assert!(is_lua_lib_name("lua.lib", &LuaVersion::Lua51));
600        assert!(is_lua_lib_name("lua-5.1.lib", &LuaVersion::Lua51));
601        assert!(!is_lua_lib_name("lua-5.2.lib", &LuaVersion::Lua51));
602        assert!(!is_lua_lib_name("lua53.lib", &LuaVersion::Lua53));
603        assert!(!is_lua_lib_name("lua53.lib", &LuaVersion::Lua53));
604        assert!(is_lua_lib_name("luajit-5.2.lib", &LuaVersion::LuaJIT52));
605        assert!(is_lua_lib_name("lua-5.2.lib", &LuaVersion::LuaJIT52));
606    }
607}