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