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