Skip to main content

lux_lib/build/
mod.rs

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