Skip to main content

lux_lib/build/
mod.rs

1use crate::build::backend::{BuildBackend, BuildInfo, RunBuildArgs};
2use crate::lockfile::{LockfileError, OptState, RemotePackageSourceUrl};
3use crate::lua_installation::LuaInstallationError;
4use crate::lua_rockspec::LuaVersionError;
5use crate::operations::{RemotePackageSourceMetadata, UnpackError};
6use crate::rockspec::{LuaVersionCompatibility, Rockspec};
7use crate::tree::{self, EntryType, TreeError};
8use bytes::Bytes;
9use std::collections::HashMap;
10use std::fs::DirEntry;
11use std::io::Cursor;
12use std::path::PathBuf;
13use std::{io, path::Path};
14
15use crate::{
16    config::Config,
17    hash::HasIntegrity,
18    lockfile::{LocalPackage, LocalPackageHashes, LockConstraint, PinnedState},
19    lua_installation::LuaInstallation,
20    lua_rockspec::BuildBackendSpec,
21    operations::{self, FetchSrcError},
22    package::PackageSpec,
23    progress::{Progress, ProgressBar},
24    remote_package_source::RemotePackageSource,
25    tree::{RockLayout, Tree},
26};
27use bon::Builder;
28use builtin::BuiltinBuildError;
29use cmake::CMakeError;
30use command::CommandError;
31use external_dependency::{ExternalDependencyError, ExternalDependencyInfo};
32
33use indicatif::style::TemplateError;
34use itertools::Itertools;
35use luarocks::LuarocksBuildError;
36use make::MakeError;
37use mlua::FromLua;
38use patch::{Patch, PatchError};
39use rust_mlua::RustError;
40use source::SourceBuildError;
41use ssri::Integrity;
42use thiserror::Error;
43use treesitter_parser::TreesitterBuildError;
44use utils::{recursive_copy_dir, CompileCFilesError, InstallBinaryError};
45
46mod builtin;
47mod cmake;
48mod command;
49mod luarocks;
50mod make;
51mod patch;
52mod rust_mlua;
53mod source;
54mod treesitter_parser;
55
56pub(crate) mod backend;
57pub(crate) mod utils;
58
59pub mod external_dependency;
60
61/// A rocks package builder, providing fine-grained control
62/// over how a package should be built.
63#[derive(Builder)]
64#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
65pub struct Build<'a, R: Rockspec + HasIntegrity> {
66    rockspec: &'a R,
67    tree: &'a Tree,
68    entry_type: tree::EntryType,
69    config: &'a Config,
70    progress: &'a Progress<ProgressBar>,
71    lua: &'a LuaInstallation,
72
73    #[builder(default)]
74    pin: PinnedState,
75    #[builder(default)]
76    opt: OptState,
77    #[builder(default)]
78    constraint: LockConstraint,
79    #[builder(default)]
80    behaviour: BuildBehaviour,
81
82    #[builder(setters(vis = "pub(crate)"))]
83    source_spec: Option<RemotePackageSourceSpec>,
84
85    // TODO(vhyrro): Remove this and enforce that this is provided at a type level.
86    #[builder(setters(vis = "pub(crate)"))]
87    source: Option<RemotePackageSource>,
88}
89
90#[derive(Debug)]
91pub(crate) enum RemotePackageSourceSpec {
92    RockSpec(Option<RemotePackageSourceUrl>),
93    SrcRock(SrcRockSource),
94}
95
96/// A packed .src.rock archive.
97#[derive(Debug)]
98pub(crate) struct SrcRockSource {
99    pub bytes: Bytes,
100    pub source_url: RemotePackageSourceUrl,
101}
102
103// Overwrite the `build()` function to use our own instead.
104impl<R: Rockspec + HasIntegrity, State> BuildBuilder<'_, R, State>
105where
106    State: build_builder::State + build_builder::IsComplete,
107{
108    pub async fn build(self) -> Result<LocalPackage, BuildError> {
109        do_build(self._build()).await
110    }
111}
112
113#[derive(Error, Debug)]
114pub enum BuildError {
115    #[error("builtin build failed: {0}")]
116    Builtin(#[from] BuiltinBuildError),
117    #[error("cmake build failed: {0}")]
118    CMake(#[from] CMakeError),
119    #[error("make build failed: {0}")]
120    Make(#[from] MakeError),
121    #[error("command build failed: {0}")]
122    Command(#[from] CommandError),
123    #[error("rust-mlua build failed: {0}")]
124    Rust(#[from] RustError),
125    #[error("treesitter-parser build failed: {0}")]
126    TreesitterBuild(#[from] TreesitterBuildError),
127    #[error("luarocks build failed: {0}")]
128    LuarocksBuild(#[from] LuarocksBuildError),
129    #[error("building from rock source failed: {0}")]
130    SourceBuild(#[from] SourceBuildError),
131    #[error("IO operation failed: {0}")]
132    Io(#[from] io::Error),
133    #[error(transparent)]
134    Lockfile(#[from] LockfileError),
135    #[error(transparent)]
136    Tree(#[from] TreeError),
137    #[error("failed to create spinner: {0}")]
138    SpinnerFailure(#[from] TemplateError),
139    #[error(transparent)]
140    ExternalDependencyError(#[from] ExternalDependencyError),
141    #[error(transparent)]
142    PatchError(#[from] PatchError),
143    #[error(transparent)]
144    CompileCFiles(#[from] CompileCFilesError),
145    #[error(transparent)]
146    LuaVersion(#[from] LuaVersionError),
147    #[error("source integrity mismatch.\nExpected: {expected},\nbut got: {actual}")]
148    SourceIntegrityMismatch {
149        expected: Integrity,
150        actual: Integrity,
151    },
152    #[error("failed to unpack src.rock:\n{0}")]
153    UnpackSrcRock(UnpackError),
154    #[error("failed to fetch rock source:\n{0}")]
155    FetchSrcError(#[from] FetchSrcError),
156    #[error("failed to install binary {0}:\n{1}")]
157    InstallBinary(String, InstallBinaryError),
158    #[error(transparent)]
159    LuaInstallation(#[from] LuaInstallationError),
160}
161
162#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
163pub enum BuildBehaviour {
164    /// Don't force a rebuild if the package is already installed
165    #[default]
166    NoForce,
167    /// Force a rebuild if the package is already installed
168    Force,
169}
170
171impl FromLua for BuildBehaviour {
172    fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
173        Ok(bool::from_lua(value, lua)?.into())
174    }
175}
176
177impl From<bool> for BuildBehaviour {
178    fn from(value: bool) -> Self {
179        if value {
180            Self::Force
181        } else {
182            Self::NoForce
183        }
184    }
185}
186
187async fn run_build<R: Rockspec + HasIntegrity>(
188    rockspec: &R,
189    args: RunBuildArgs<'_>,
190) -> Result<BuildInfo, BuildError> {
191    let progress = args.progress;
192    progress.map(|p| p.set_message("🛠️ Building..."));
193
194    Ok(
195        match rockspec.build().current_platform().build_backend.to_owned() {
196            Some(BuildBackendSpec::Builtin(build_spec)) => build_spec.run(args).await?,
197            Some(BuildBackendSpec::Make(make_spec)) => make_spec.run(args).await?,
198            Some(BuildBackendSpec::CMake(cmake_spec)) => cmake_spec.run(args).await?,
199            Some(BuildBackendSpec::Command(command_spec)) => command_spec.run(args).await?,
200            Some(BuildBackendSpec::RustMlua(rust_mlua_spec)) => rust_mlua_spec.run(args).await?,
201            Some(BuildBackendSpec::TreesitterParser(treesitter_parser_spec)) => {
202                treesitter_parser_spec.run(args).await?
203            }
204            Some(BuildBackendSpec::LuaRock(_)) => luarocks::build(rockspec, args).await?,
205            Some(BuildBackendSpec::Source) => source::build(args).await?,
206            None => BuildInfo::default(),
207        },
208    )
209}
210
211#[allow(clippy::too_many_arguments)]
212async fn install<R: Rockspec + HasIntegrity>(
213    rockspec: &R,
214    tree: &Tree,
215    output_paths: &RockLayout,
216    lua: &LuaInstallation,
217    build_dir: &Path,
218    entry_type: &EntryType,
219    progress: &Progress<ProgressBar>,
220    config: &Config,
221) -> Result<(), BuildError> {
222    progress.map(|p| {
223        p.set_message(format!(
224            "💻 Installing {} {}",
225            rockspec.package(),
226            rockspec.version()
227        ))
228    });
229
230    let install_spec = &rockspec.build().current_platform().install;
231    let lua_len = install_spec.lua.len();
232    let lib_len = install_spec.lib.len();
233    let bin_len = install_spec.bin.len();
234    let conf_len = install_spec.conf.len();
235    let total_len = lua_len + lib_len + bin_len + conf_len;
236    progress.map(|p| p.set_position(total_len as u64));
237
238    if lua_len > 0 {
239        progress.map(|p| p.set_message("📋 Copying Lua modules..."));
240    }
241    for (target, source) in &install_spec.lua {
242        let absolute_source = build_dir.join(source);
243        utils::copy_lua_to_module_path(&absolute_source, target, &output_paths.src)?;
244        progress.map(|p| p.set_position(p.position() + 1));
245    }
246    if lib_len > 0 {
247        progress.map(|p| p.set_message("📋 Compiling C libraries..."));
248    }
249    for (target, source) in &install_spec.lib {
250        let absolute_source = build_dir.join(source);
251        let resolved_target = output_paths.lib.join(target);
252        tokio::fs::copy(absolute_source, resolved_target).await?;
253        progress.map(|p| p.set_position(p.position() + 1));
254    }
255    if entry_type.is_entrypoint() {
256        if bin_len > 0 {
257            progress.map(|p| p.set_message("💻 Installing binaries..."));
258        }
259        let deploy_spec = rockspec.deploy().current_platform();
260        for (target, source) in &install_spec.bin {
261            utils::install_binary(
262                &build_dir.join(source),
263                target,
264                tree,
265                lua,
266                deploy_spec,
267                config,
268            )
269            .await
270            .map_err(|err| BuildError::InstallBinary(target.clone(), err))?;
271            progress.map(|p| p.set_position(p.position() + 1));
272        }
273    }
274    if conf_len > 0 {
275        progress.map(|p| p.set_message("📋 Copying configuration files..."));
276        for (target, source) in &install_spec.conf {
277            let absolute_source = build_dir.join(source);
278            let target = output_paths.conf.join(target);
279            if let Some(parent_dir) = target.parent() {
280                tokio::fs::create_dir_all(parent_dir).await?;
281            }
282            tokio::fs::copy(absolute_source, target).await?;
283            progress.map(|p| p.set_position(p.position() + 1));
284        }
285    }
286    Ok(())
287}
288
289async fn do_build<R>(build: Build<'_, R>) -> Result<LocalPackage, BuildError>
290where
291    R: Rockspec + HasIntegrity,
292{
293    let rockspec = build.rockspec;
294
295    build.progress.map(|p| {
296        p.set_message(format!(
297            "🛠️ Building {}@{}...",
298            rockspec.package(),
299            rockspec.version()
300        ))
301    });
302
303    let lua = build.lua;
304
305    rockspec.validate_lua_version(&lua.version)?;
306
307    let tree = build.tree;
308
309    let temp_dir = tempfile::tempdir()?;
310
311    let source_metadata = match build.source_spec {
312        Some(RemotePackageSourceSpec::SrcRock(SrcRockSource { bytes, source_url })) => {
313            let hash = bytes.hash()?;
314            let cursor = Cursor::new(&bytes);
315            operations::unpack_src_rock(cursor, temp_dir.path().to_path_buf(), build.progress)
316                .await
317                .map_err(BuildError::UnpackSrcRock)?;
318            RemotePackageSourceMetadata { hash, source_url }
319        }
320        Some(RemotePackageSourceSpec::RockSpec(source_url)) => {
321            operations::FetchSrc::new(temp_dir.path(), rockspec, build.config, build.progress)
322                .maybe_source_url(source_url)
323                .fetch_internal()
324                .await?
325        }
326        None => {
327            operations::FetchSrc::new(temp_dir.path(), rockspec, build.config, build.progress)
328                .fetch_internal()
329                .await?
330        }
331    };
332
333    let hashes = LocalPackageHashes {
334        rockspec: rockspec.hash()?,
335        source: source_metadata.hash.clone(),
336    };
337
338    let mut package = LocalPackage::from(
339        &PackageSpec::new(rockspec.package().clone(), rockspec.version().clone()),
340        build.constraint,
341        rockspec.binaries(),
342        build
343            .source
344            .map(Result::Ok)
345            .unwrap_or_else(|| {
346                rockspec
347                    .to_lua_remote_rockspec_string()
348                    .map(RemotePackageSource::RockspecContent)
349            })
350            .unwrap_or(RemotePackageSource::Local),
351        Some(source_metadata.source_url.clone()),
352        hashes,
353    );
354    package.spec.pinned = build.pin;
355    package.spec.opt = build.opt;
356
357    match tree.lockfile()?.get(&package.id()) {
358        Some(package) if build.behaviour == BuildBehaviour::NoForce => Ok(package.clone()),
359        _ => {
360            let output_paths = match build.entry_type {
361                tree::EntryType::Entrypoint => tree.entrypoint(&package)?,
362                tree::EntryType::DependencyOnly => tree.dependency(&package)?,
363            };
364
365            let rock_source = rockspec.source().current_platform();
366            let build_dir = match &rock_source.unpack_dir {
367                Some(unpack_dir) => temp_dir.path().join(unpack_dir),
368                None => {
369                    // Some older/off-spec rockspecs don't specify a `source.dir`.
370                    // After unpacking the archive, if
371                    //
372                    //   - there exist no Lua or C sources
373                    //   - there exists a single subdirectory that is not a source
374                    //     or etc directory
375                    //
376                    // we assume it's the `source.dir`.
377                    // Unlike the LuaRocks implementation - which filters when fetching sources -
378                    // we only infer `source.dir` if the directory name is not 'src', 'lua'
379                    // or one of the `build.copy_directories`.
380                    // This allows us to build local projects with only a `src` directory.
381                    //
382                    // LuaRocks implementation:
383                    // https://github.com/luarocks/luarocks/blob/4188fdb235aca66530d274c782374cf6afba09b8/src/luarocks/fetch.tl?plain=1#L526
384                    let has_lua_or_c_sources = std::fs::read_dir(temp_dir.path())?
385                        .filter_map(Result::ok)
386                        .filter(|f| f.path().is_file())
387                        .any(|f| {
388                            f.path().extension().is_some_and(|ext| {
389                                matches!(ext.to_string_lossy().to_string().as_str(), "lua" | "c")
390                            })
391                        });
392                    if has_lua_or_c_sources {
393                        temp_dir.path().into()
394                    } else {
395                        let dir_entries = std::fs::read_dir(temp_dir.path())?
396                            .filter_map(Result::ok)
397                            .filter(|f| f.path().is_dir())
398                            .collect_vec();
399                        if dir_entries.len() == 1
400                            && !is_source_or_etc_dir(
401                                unsafe { dir_entries.first().unwrap_unchecked() },
402                                rockspec,
403                            )
404                        {
405                            unsafe {
406                                temp_dir
407                                    .path()
408                                    .join(dir_entries.first().unwrap_unchecked().path())
409                            }
410                        } else {
411                            temp_dir.path().into()
412                        }
413                    }
414                }
415            };
416
417            Patch::new(
418                &build_dir,
419                &rockspec.build().current_platform().patches,
420                build.progress,
421            )
422            .apply()?;
423
424            let external_dependencies = rockspec
425                .external_dependencies()
426                .current_platform()
427                .iter()
428                .map(|(name, dep)| {
429                    ExternalDependencyInfo::probe(name, dep, build.config.external_deps())
430                        .map(|info| (name.clone(), info))
431                })
432                .try_collect::<_, HashMap<_, _>, _>()?;
433
434            let output = run_build(
435                rockspec,
436                RunBuildArgs::new()
437                    .output_paths(&output_paths)
438                    .no_install(false)
439                    .lua(lua)
440                    .external_dependencies(&external_dependencies)
441                    .deploy(rockspec.deploy().current_platform())
442                    .config(build.config)
443                    .tree(tree)
444                    .build_dir(&build_dir)
445                    .progress(build.progress)
446                    .build(),
447            )
448            .await?;
449
450            package.spec.binaries.extend(output.binaries);
451
452            install(
453                rockspec,
454                tree,
455                &output_paths,
456                lua,
457                &build_dir,
458                &build.entry_type,
459                build.progress,
460                build.config,
461            )
462            .await?;
463
464            for directory in rockspec
465                .build()
466                .current_platform()
467                .copy_directories
468                .iter()
469                .filter(|dir| {
470                    dir.file_name()
471                        .is_some_and(|name| name != "doc" && name != "docs")
472                })
473            {
474                recursive_copy_dir(
475                    &build_dir.join(directory),
476                    &output_paths.etc.join(directory),
477                )
478                .await?;
479            }
480
481            recursive_copy_doc_dir(&output_paths, &build_dir).await?;
482
483            if let Ok(rockspec_str) = rockspec.to_lua_remote_rockspec_string() {
484                std::fs::write(output_paths.rockspec_path(), rockspec_str)?;
485            }
486
487            Ok(package)
488        }
489    }
490}
491
492fn is_source_or_etc_dir<R>(dir: &DirEntry, rockspec: &R) -> bool
493where
494    R: Rockspec + HasIntegrity,
495{
496    let copy_dirs = &rockspec.build().current_platform().copy_directories;
497    let dir_name = dir.file_name().to_string_lossy().to_string();
498    matches!(dir_name.as_str(), "lua" | "src")
499        || copy_dirs
500            .iter()
501            .any(|copy_dir_name| copy_dir_name == &PathBuf::from(&dir_name))
502}
503
504async fn recursive_copy_doc_dir(
505    output_paths: &RockLayout,
506    build_dir: &Path,
507) -> Result<(), BuildError> {
508    let mut doc_dir = build_dir.join("doc");
509    if !doc_dir.exists() {
510        doc_dir = build_dir.join("docs");
511    }
512    recursive_copy_dir(&doc_dir, &output_paths.doc).await?;
513    Ok(())
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use predicates::prelude::*;
520    use std::path::PathBuf;
521
522    use assert_fs::{
523        assert::PathAssert,
524        prelude::{PathChild, PathCopy},
525    };
526
527    use crate::{
528        config::{ConfigBuilder, LuaVersion},
529        lua_installation::{detect_installed_lua_version, LuaInstallation},
530        progress::MultiProgress,
531        project::Project,
532        tree::RockLayout,
533    };
534
535    #[tokio::test]
536    async fn test_builtin_build() {
537        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
538        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
539            .join("resources/test/sample-projects/no-build-spec/");
540        let tree_dir = assert_fs::TempDir::new().unwrap();
541        let config = ConfigBuilder::new()
542            .unwrap()
543            .lua_version(lua_version)
544            .user_tree(Some(tree_dir.to_path_buf()))
545            .build()
546            .unwrap();
547        let build_dir = assert_fs::TempDir::new().unwrap();
548        build_dir.copy_from(&project_root, &["**"]).unwrap();
549        let tree = config
550            .user_tree(config.lua_version().cloned().unwrap())
551            .unwrap();
552        let dest_dir = assert_fs::TempDir::new().unwrap();
553        let rock_layout = RockLayout {
554            rock_path: dest_dir.to_path_buf(),
555            etc: dest_dir.join("etc"),
556            lib: dest_dir.join("lib"),
557            src: dest_dir.join("src"),
558            bin: tree.bin(),
559            conf: dest_dir.join("conf"),
560            doc: dest_dir.join("doc"),
561        };
562        let lua_version = config.lua_version().unwrap_or(&LuaVersion::Lua51);
563        let progress = MultiProgress::new(&config);
564        let bar = progress.map(MultiProgress::new_bar);
565        let lua = LuaInstallation::new(lua_version, &config, &bar)
566            .await
567            .unwrap();
568        let project = Project::from(&project_root).unwrap().unwrap();
569        let rockspec = project.toml().into_remote(None).unwrap();
570        let progress = MultiProgress::new(&config);
571        run_build(
572            &rockspec,
573            RunBuildArgs::new()
574                .output_paths(&rock_layout)
575                .no_install(false)
576                .lua(&lua)
577                .external_dependencies(&HashMap::default())
578                .deploy(rockspec.deploy().current_platform())
579                .config(&config)
580                .tree(&tree)
581                .build_dir(&build_dir)
582                .progress(&progress.map(|p| p.new_bar()))
583                .build(),
584        )
585        .await
586        .unwrap();
587        let foo_dir = dest_dir.child("src").child("foo");
588        foo_dir.assert(predicate::path::is_dir());
589        let foo_init = foo_dir.child("init.lua");
590        foo_init.assert(predicate::path::is_file());
591        foo_init.assert(predicate::str::contains("return true"));
592        let foo_bar_dir = foo_dir.child("bar");
593        foo_bar_dir.assert(predicate::path::is_dir());
594        let foo_bar_init = foo_bar_dir.child("init.lua");
595        foo_bar_init.assert(predicate::path::is_file());
596        foo_bar_init.assert(predicate::str::contains("return true"));
597        let foo_bar_baz = foo_bar_dir.child("baz.lua");
598        foo_bar_baz.assert(predicate::path::is_file());
599        foo_bar_baz.assert(predicate::str::contains("return true"));
600        let bin_file = tree_dir
601            .child(lua_version.to_string())
602            .child("bin")
603            .child("hello");
604        bin_file.assert(predicate::path::is_file());
605        bin_file.assert(predicate::str::contains("#!/usr/bin/env bash"));
606        bin_file.assert(predicate::str::contains("echo \"Hello\""));
607    }
608}