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