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