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