Skip to main content

lux_lib/build/
mod.rs

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