Skip to main content

lux_lib/build/
builtin.rs

1use itertools::Itertools;
2use miette::Diagnostic;
3use std::{
4    collections::{HashMap, HashSet},
5    path::{Path, PathBuf},
6    str::FromStr,
7};
8use thiserror::Error;
9use walkdir::WalkDir;
10
11use crate::{
12    build::{
13        backend::{BuildBackend, BuildInfo, RunBuildArgs},
14        compile::{c_files, c_modules},
15    },
16    fs,
17    lua_rockspec::{BuiltinBuildSpec, LuaModule, ModuleSpec, ParseLuaModuleError},
18    tree::{self, InstallTree},
19};
20
21#[cfg(not(feature = "luau-transpile"))]
22use crate::lua_version::LuaVersion;
23
24use super::{compile, install};
25
26#[derive(Error, Debug, Diagnostic)]
27#[non_exhaustive]
28pub enum Error {
29    #[error(transparent)]
30    #[diagnostic(transparent)]
31    CompileCFiles(#[from] compile::c_files::Error),
32    #[error(transparent)]
33    #[diagnostic(transparent)]
34    CompileCModules(#[from] compile::c_modules::Error),
35    #[error("failed to install binary '{file_name}'")]
36    InstallBinary {
37        file_name: String,
38        #[diagnostic_source]
39        source: install::Error,
40    },
41    #[error(transparent)]
42    #[diagnostic(transparent)]
43    Fs(#[from] fs::Error),
44    #[error(transparent)]
45    #[diagnostic(transparent)]
46    Tree(#[from] tree::Error),
47    #[error("module auto-detection failed")]
48    #[diagnostic(transparent)]
49    AutoDetectModules(#[from] AutoDetectModulesError),
50    #[error("luau does not support native dependencies")]
51    #[diagnostic(help(
52        "the luau runtime cannot load native libraries. use pure-Luau packages or Lua"
53    ))]
54    LuauNativeDepsUnsupported,
55    #[cfg(feature = "luau-transpile")]
56    #[error(transparent)]
57    #[diagnostic(transparent)]
58    LuauTranspile(#[from] super::luau::Error),
59    #[cfg(not(feature = "luau-transpile"))]
60    #[error("cannot install Luau module `{module}` on {lua_version}")]
61    #[diagnostic(help(
62        "this build of Lux was compiled without support for transpiling Luau to Lua"
63    ))]
64    LuauTranspileDisabled {
65        module: String,
66        lua_version: LuaVersion,
67    },
68}
69
70impl BuildBackend for BuiltinBuildSpec {
71    type Err = Error;
72
73    #[tracing::instrument(name = "builtin::run", skip_all, level = "debug")]
74    async fn run<T>(self, args: RunBuildArgs<'_, T>) -> Result<BuildInfo, Self::Err>
75    where
76        T: InstallTree + Sync,
77    {
78        let package = args.package;
79        let lua = args.lua;
80        let external_dependencies = args.external_dependencies;
81        let config = args.config;
82        let tree = args.tree;
83        let layout = tree.layout_for(package);
84        let build_dir = args.build_dir;
85
86        // Detect all Lua modules
87        let modules = autodetect_modules(build_dir, source_paths(build_dir, &self.modules))?
88            .into_iter()
89            .chain(self.modules)
90            .collect::<HashMap<_, _>>();
91
92        if lua.version.is_luau() {
93            let has_native_modules = modules.values().any(|spec| match spec {
94                ModuleSpec::SourcePath(source) => source.extension().is_some_and(|ext| ext == "c"),
95                ModuleSpec::SourcePaths(_) | ModuleSpec::ModulePaths(_) => true,
96            });
97            if has_native_modules {
98                return Err(Error::LuauNativeDepsUnsupported);
99            }
100        }
101
102        for (destination_path, module_type) in modules.iter() {
103            match module_type {
104                ModuleSpec::SourcePath(source) => {
105                    if source.extension().map(|ext| ext == "c").unwrap_or(false) {
106                        let absolute_source_paths = vec![build_dir.join(source)];
107                        c_files::compile(
108                            &absolute_source_paths,
109                            destination_path,
110                            &layout.lib,
111                            lua,
112                            external_dependencies,
113                            config,
114                        )
115                        .await?;
116                    } else if source.extension().is_some_and(|ext| ext == "luau")
117                        && !lua.version.is_luau()
118                    {
119                        #[cfg(feature = "luau-transpile")]
120                        {
121                            let absolute_source_path = build_dir.join(source);
122                            super::luau::transpile_luau_to_lua_module(
123                                &absolute_source_path,
124                                destination_path,
125                                &layout.src,
126                            )
127                            .await?;
128                        }
129                        #[cfg(not(feature = "luau-transpile"))]
130                        return Err(Error::LuauTranspileDisabled {
131                            module: destination_path.to_string(),
132                            lua_version: lua.version.clone(),
133                        });
134                    } else {
135                        let absolute_source_path = build_dir.join(source);
136                        install::lua_module(&absolute_source_path, destination_path, &layout.src)?;
137                    }
138                }
139                ModuleSpec::SourcePaths(files) => {
140                    let absolute_source_paths =
141                        files.iter().map(|file| build_dir.join(file)).collect();
142                    c_files::compile(
143                        &absolute_source_paths,
144                        destination_path,
145                        &layout.lib,
146                        lua,
147                        external_dependencies,
148                        config,
149                    )
150                    .await?
151                }
152                ModuleSpec::ModulePaths(data) => {
153                    c_modules::compile(
154                        data,
155                        build_dir,
156                        destination_path,
157                        &layout.lib,
158                        lua,
159                        external_dependencies,
160                        config,
161                    )
162                    .await?
163                }
164            }
165        }
166
167        let mut binaries = Vec::new();
168        for bin_script in install::detect_binaries(build_dir) {
169            if let Some(target) = bin_script.file_name() {
170                let file_name = target.to_string_lossy().to_string();
171                let installed_bin_script =
172                    install::binary(&bin_script, &file_name, tree, lua, args.deploy, config)
173                        .await
174                        .map_err(|err| Error::InstallBinary {
175                            file_name: file_name.clone(),
176                            source: err,
177                        })?;
178                if let Some(bin_script_file_name) = installed_bin_script.file_name() {
179                    binaries.push(bin_script_file_name.into());
180                }
181            }
182        }
183
184        Ok(BuildInfo { binaries })
185    }
186}
187
188fn source_paths(build_dir: &Path, modules: &HashMap<LuaModule, ModuleSpec>) -> HashSet<PathBuf> {
189    modules
190        .values()
191        .flat_map(|spec| match spec {
192            ModuleSpec::SourcePath(path_buf) => vec![path_buf],
193            ModuleSpec::SourcePaths(vec) => vec.iter().collect_vec(),
194            ModuleSpec::ModulePaths(module_paths) => module_paths.sources.iter().collect_vec(),
195        })
196        .map(|path| build_dir.join(path))
197        .collect()
198}
199
200#[derive(Error, Debug, Diagnostic)]
201#[non_exhaustive]
202pub enum AutoDetectModulesError {
203    #[error(transparent)]
204    #[diagnostic(transparent)]
205    ParseLuaModule(#[from] ParseLuaModuleError),
206}
207
208#[tracing::instrument(level = "trace")]
209pub(crate) fn autodetect_modules(
210    build_dir: &Path,
211    exclude: HashSet<PathBuf>,
212) -> Result<HashMap<LuaModule, ModuleSpec>, AutoDetectModulesError> {
213    WalkDir::new(build_dir.join("src"))
214        .into_iter()
215        .chain(WalkDir::new(build_dir.join("lua")))
216        .chain(WalkDir::new(build_dir.join("lib")))
217        .filter_map(|file| {
218            file.ok().and_then(|file| {
219                let is_lua_file = PathBuf::from(file.file_name())
220                    .extension()
221                    .is_some_and(|ext| ext == "lua" || ext == "luau");
222                if is_lua_file && !exclude.contains(&file.clone().into_path()) {
223                    Some(file)
224                } else {
225                    None
226                }
227            })
228        })
229        .map(|file| {
230            let diff: PathBuf = unsafe {
231                pathdiff::diff_paths(build_dir.join(file.clone().into_path()), build_dir)
232                    .unwrap_unchecked()
233            };
234
235            // NOTE(vhyrro): You may ask why we convert all paths to Lua module paths
236            // just to convert them back later in the `run()` stage.
237            //
238            // The rockspec requires the format to be like this, and representing our
239            // data in this form allows us to respect any overrides made by the user (which follow
240            // the `module.name` format, not our internal one).
241            let mut pathbuf = diff.components().skip(1).collect::<PathBuf>();
242            let lua_module = if pathbuf
243                .parent()
244                .is_none_or(|parent| parent.as_os_str().is_empty())
245            {
246                pathbuf.set_extension("");
247                Ok(LuaModule::from_pathbuf(pathbuf)?)
248            } else {
249                let mut lua_module = LuaModule::from_pathbuf(pathbuf)?;
250                // NOTE(mrcjkb): `LuaModule` does not parse as "<module>.init" from files named "init.lua"
251                // To make sure we don't change the file structure when installing, we append it here.
252                if matches!(
253                    file.file_name().to_string_lossy().as_bytes(),
254                    b"init.lua" | b"init.luau"
255                ) {
256                    unsafe {
257                        lua_module =
258                            lua_module.join(&LuaModule::from_str("init").unwrap_unchecked())
259                    }
260                }
261                Ok(lua_module)
262            };
263            lua_module.map(|lua_module| (lua_module, ModuleSpec::SourcePath(diff)))
264        })
265        .try_collect()
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn write_file(path: &Path) {
273        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
274        std::fs::write(path, b"return {}").unwrap();
275    }
276
277    #[tokio::test]
278    async fn test_auto_detect_modules() {
279        let build_dir = tempfile::tempdir().unwrap();
280        let root = build_dir.path();
281        write_file(&root.join("src").join("foo.luau"));
282        write_file(&root.join("src").join("bar").join("baz.luau"));
283        write_file(&root.join("src").join("baz").join("init.luau"));
284        write_file(&root.join("lua").join("legacy.lua"));
285
286        let modules = autodetect_modules(root, Default::default()).unwrap();
287
288        let names = modules.keys().map(|m| m.to_string()).collect_vec();
289        assert!(names.contains(&"foo".to_string()), "{names:?}");
290        assert!(names.contains(&"bar.baz".to_string()), "{names:?}");
291        assert!(names.contains(&"baz.init".to_string()), "{names:?}");
292        assert!(names.contains(&"legacy".to_string()), "{names:?}");
293
294        let foo_spec = &modules[&"foo".parse().unwrap()];
295        match foo_spec {
296            ModuleSpec::SourcePath(path) => {
297                assert_eq!(path.extension().unwrap(), "luau");
298            }
299            _ => panic!("expected SourcePath for foo.luau"),
300        }
301    }
302}