lux-lib 0.67.3

Library for the lux package manager for Lua
Documentation
use itertools::Itertools;
use miette::Diagnostic;
use std::{
    collections::{HashMap, HashSet},
    path::{Path, PathBuf},
    str::FromStr,
};
use thiserror::Error;
use walkdir::WalkDir;

use crate::{
    build::{
        backend::{BuildBackend, BuildInfo, RunBuildArgs},
        utils,
    },
    fs,
    lua_rockspec::{BuiltinBuildSpec, LuaModule, ModuleSpec, ParseLuaModuleError},
    tree::{InstallTree, TreeError},
};

#[cfg(not(feature = "luau-transpile"))]
use crate::lua_version::LuaVersion;

use super::utils::{CompileCFilesError, CompileCModulesError, InstallBinaryError};

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum BuiltinBuildError {
    #[error(transparent)]
    #[diagnostic(transparent)]
    CompileCFiles(#[from] CompileCFilesError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    CompileCModules(#[from] CompileCModulesError),
    #[error("failed to install binary '{file_name}'")]
    InstallBinary {
        file_name: String,
        #[diagnostic_source]
        source: InstallBinaryError,
    },
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Tree(#[from] TreeError),
    #[error("module auto-detection failed")]
    #[diagnostic(transparent)]
    AutoDetectModules(#[from] AutoDetectModulesError),
    #[error("luau does not support native dependencies")]
    #[diagnostic(help(
        "the luau runtime cannot load native libraries. use pure-Luau packages or Lua"
    ))]
    LuauNativeDepsUnsupported,
    #[cfg(feature = "luau-transpile")]
    #[error(transparent)]
    #[diagnostic(transparent)]
    LuauTranspile(#[from] super::luau::LuauTranspileError),
    #[cfg(not(feature = "luau-transpile"))]
    #[error("cannot install Luau module `{module}` on {lua_version}")]
    #[diagnostic(help(
        "this build of Lux was compiled without support for transpiling Luau to Lua"
    ))]
    LuauTranspileDisabled {
        module: String,
        lua_version: LuaVersion,
    },
}

impl BuildBackend for BuiltinBuildSpec {
    type Err = BuiltinBuildError;

    #[tracing::instrument(name = "builtin::run", skip_all, level = "debug")]
    async fn run<T>(self, args: RunBuildArgs<'_, T>) -> Result<BuildInfo, Self::Err>
    where
        T: InstallTree + Sync,
    {
        let package = args.package;
        let lua = args.lua;
        let external_dependencies = args.external_dependencies;
        let config = args.config;
        let tree = args.tree;
        let layout = tree.layout_for(package);
        let build_dir = args.build_dir;

        // Detect all Lua modules
        let modules = autodetect_modules(build_dir, source_paths(build_dir, &self.modules))?
            .into_iter()
            .chain(self.modules)
            .collect::<HashMap<_, _>>();

        if lua.version.is_luau() {
            let has_native_modules = modules.values().any(|spec| match spec {
                ModuleSpec::SourcePath(source) => source.extension().is_some_and(|ext| ext == "c"),
                ModuleSpec::SourcePaths(_) | ModuleSpec::ModulePaths(_) => true,
            });
            if has_native_modules {
                return Err(BuiltinBuildError::LuauNativeDepsUnsupported);
            }
        }

        for (destination_path, module_type) in modules.iter() {
            match module_type {
                ModuleSpec::SourcePath(source) => {
                    if source.extension().map(|ext| ext == "c").unwrap_or(false) {
                        let absolute_source_paths = vec![build_dir.join(source)];
                        utils::compile_c_files(
                            &absolute_source_paths,
                            destination_path,
                            &layout.lib,
                            lua,
                            external_dependencies,
                            config,
                        )
                        .await?;
                    } else if source.extension().is_some_and(|ext| ext == "luau")
                        && !lua.version.is_luau()
                    {
                        #[cfg(feature = "luau-transpile")]
                        {
                            let absolute_source_path = build_dir.join(source);
                            super::luau::transpile_luau_to_lua_module(
                                &absolute_source_path,
                                destination_path,
                                &layout.src,
                            )
                            .await?;
                        }
                        #[cfg(not(feature = "luau-transpile"))]
                        return Err(BuiltinBuildError::LuauTranspileDisabled {
                            module: destination_path.to_string(),
                            lua_version: lua.version.clone(),
                        });
                    } else {
                        let absolute_source_path = build_dir.join(source);
                        utils::copy_lua_to_module_path(
                            &absolute_source_path,
                            destination_path,
                            &layout.src,
                        )?;
                    }
                }
                ModuleSpec::SourcePaths(files) => {
                    let absolute_source_paths =
                        files.iter().map(|file| build_dir.join(file)).collect();
                    utils::compile_c_files(
                        &absolute_source_paths,
                        destination_path,
                        &layout.lib,
                        lua,
                        external_dependencies,
                        config,
                    )
                    .await?
                }
                ModuleSpec::ModulePaths(data) => {
                    utils::compile_c_modules(
                        data,
                        build_dir,
                        destination_path,
                        &layout.lib,
                        lua,
                        external_dependencies,
                        config,
                    )
                    .await?
                }
            }
        }

        let mut binaries = Vec::new();
        for bin_script in utils::detect_binaries(build_dir) {
            if let Some(target) = bin_script.file_name() {
                let file_name = target.to_string_lossy().to_string();
                let installed_bin_script =
                    utils::install_binary(&bin_script, &file_name, tree, lua, args.deploy, config)
                        .await
                        .map_err(|err| BuiltinBuildError::InstallBinary {
                            file_name: file_name.clone(),
                            source: err,
                        })?;
                if let Some(bin_script_file_name) = installed_bin_script.file_name() {
                    binaries.push(bin_script_file_name.into());
                }
            }
        }

        Ok(BuildInfo { binaries })
    }
}

fn source_paths(build_dir: &Path, modules: &HashMap<LuaModule, ModuleSpec>) -> HashSet<PathBuf> {
    modules
        .values()
        .flat_map(|spec| match spec {
            ModuleSpec::SourcePath(path_buf) => vec![path_buf],
            ModuleSpec::SourcePaths(vec) => vec.iter().collect_vec(),
            ModuleSpec::ModulePaths(module_paths) => module_paths.sources.iter().collect_vec(),
        })
        .map(|path| build_dir.join(path))
        .collect()
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum AutoDetectModulesError {
    #[error(transparent)]
    #[diagnostic(transparent)]
    ParseLuaModule(#[from] ParseLuaModuleError),
}

#[tracing::instrument(level = "trace")]
pub(crate) fn autodetect_modules(
    build_dir: &Path,
    exclude: HashSet<PathBuf>,
) -> Result<HashMap<LuaModule, ModuleSpec>, AutoDetectModulesError> {
    WalkDir::new(build_dir.join("src"))
        .into_iter()
        .chain(WalkDir::new(build_dir.join("lua")))
        .chain(WalkDir::new(build_dir.join("lib")))
        .filter_map(|file| {
            file.ok().and_then(|file| {
                let is_lua_file = PathBuf::from(file.file_name())
                    .extension()
                    .is_some_and(|ext| ext == "lua" || ext == "luau");
                if is_lua_file && !exclude.contains(&file.clone().into_path()) {
                    Some(file)
                } else {
                    None
                }
            })
        })
        .map(|file| {
            let diff: PathBuf = unsafe {
                pathdiff::diff_paths(build_dir.join(file.clone().into_path()), build_dir)
                    .unwrap_unchecked()
            };

            // NOTE(vhyrro): You may ask why we convert all paths to Lua module paths
            // just to convert them back later in the `run()` stage.
            //
            // The rockspec requires the format to be like this, and representing our
            // data in this form allows us to respect any overrides made by the user (which follow
            // the `module.name` format, not our internal one).
            let mut pathbuf = diff.components().skip(1).collect::<PathBuf>();
            let lua_module = if pathbuf
                .parent()
                .is_none_or(|parent| parent.as_os_str().is_empty())
            {
                pathbuf.set_extension("");
                Ok(LuaModule::from_pathbuf(pathbuf)?)
            } else {
                let mut lua_module = LuaModule::from_pathbuf(pathbuf)?;
                // NOTE(mrcjkb): `LuaModule` does not parse as "<module>.init" from files named "init.lua"
                // To make sure we don't change the file structure when installing, we append it here.
                if matches!(
                    file.file_name().to_string_lossy().as_bytes(),
                    b"init.lua" | b"init.luau"
                ) {
                    unsafe {
                        lua_module =
                            lua_module.join(&LuaModule::from_str("init").unwrap_unchecked())
                    }
                }
                Ok(lua_module)
            };
            lua_module.map(|lua_module| (lua_module, ModuleSpec::SourcePath(diff)))
        })
        .try_collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write_file(path: &Path) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, b"return {}").unwrap();
    }

    #[tokio::test]
    async fn test_auto_detect_modules() {
        let build_dir = tempfile::tempdir().unwrap();
        let root = build_dir.path();
        write_file(&root.join("src").join("foo.luau"));
        write_file(&root.join("src").join("bar").join("baz.luau"));
        write_file(&root.join("src").join("baz").join("init.luau"));
        write_file(&root.join("lua").join("legacy.lua"));

        let modules = autodetect_modules(root, Default::default()).unwrap();

        let names = modules.keys().map(|m| m.to_string()).collect_vec();
        assert!(names.contains(&"foo".to_string()), "{names:?}");
        assert!(names.contains(&"bar.baz".to_string()), "{names:?}");
        assert!(names.contains(&"baz.init".to_string()), "{names:?}");
        assert!(names.contains(&"legacy".to_string()), "{names:?}");

        let foo_spec = &modules[&"foo".parse().unwrap()];
        match foo_spec {
            ModuleSpec::SourcePath(path) => {
                assert_eq!(path.extension().unwrap(), "luau");
            }
            _ => panic!("expected SourcePath for foo.luau"),
        }
    }
}