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#[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 #[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#[derive(Debug)]
98pub(crate) struct SrcRockSource {
99 pub bytes: Bytes,
100 pub source_url: RemotePackageSourceUrl,
101}
102
103impl<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 #[default]
206 NoForce,
207 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 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}