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