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