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#[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 = 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 #[default]
204 NoForce,
205 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 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}