Skip to main content

lux_lib/build/
external_dependency.rs

1use crate::{
2    config::external_deps::ExternalDependencySearchConfig,
3    lua_rockspec::ExternalDependencySpec,
4    variables::{GetVariableError, HasVariables},
5};
6use itertools::Itertools;
7use miette::Diagnostic;
8use path_slash::{PathBufExt, PathExt};
9use pkg_config::{Config as PkgConfig, Library};
10use std::{
11    collections::HashMap,
12    path::{Path, PathBuf},
13};
14use thiserror::Error;
15
16use super::utils::{c_lib_extension, format_path};
17
18use crate::fs;
19
20#[derive(Error, Debug, Diagnostic)]
21#[non_exhaustive]
22pub enum ExternalDependencyError {
23    #[error("external dependency '{0}' not found")]
24    #[diagnostic(help("{}", not_found_help(.0)))]
25    NotFound(String),
26    #[error(transparent)]
27    #[diagnostic(transparent)]
28    Fs(#[from] fs::FsError),
29    #[error("{0} was probed successfully, but the header {1} could not be found")]
30    #[diagnostic(help(
31        r#"ensure the header is installed.
32As a fallback, set {0}_INCDIR to the include directory."#
33    ))]
34    SuccessfulProbeHeaderNotFound(String, String),
35    #[error("error probing external dependency {0}: the header {1} could not be found")]
36    #[diagnostic(help(
37        r#"ensure the package is installed with pkg-config support,
38or set {0}_INCDIR to the directory containing the header."#
39    ))]
40    HeaderNotFound(String, String),
41    #[error("error probing external dependency {0}: the library {1} could not be found")]
42    #[diagnostic(help(
43        r#"ensure the package is installed with pkg-config support,
44or set {0}_LIBDIR to the directory containing the library."#
45    ))]
46    LibraryNotFound(String, String),
47}
48
49#[derive(Debug)]
50pub struct ExternalDependencyInfo {
51    pub(crate) include_dir: Option<PathBuf>,
52    pub(crate) lib_dir: Option<PathBuf>,
53    pub(crate) bin_dir: Option<PathBuf>,
54    /// Name of the static library, (without the 'lib' prefix or file extension on unix targets),
55    /// for example, "foo" or "foo.dll"
56    pub(crate) lib_name: Option<String>,
57    /// pkg-config library information if available
58    pub(crate) lib_info: Option<Library>,
59}
60
61#[tracing::instrument(level = "trace")]
62fn pkg_config_probe(name: &str) -> Option<Library> {
63    PkgConfig::new()
64        .print_system_libs(false)
65        .cargo_metadata(false)
66        .env_metadata(false)
67        .probe(&name.to_lowercase())
68        .ok()
69}
70
71impl ExternalDependencyInfo {
72    #[tracing::instrument(level = "trace", skip(config))]
73    pub fn probe(
74        name: &str,
75        dependency: &ExternalDependencySpec,
76        config: &ExternalDependencySearchConfig,
77    ) -> Result<Self, ExternalDependencyError> {
78        let lib_info = pkg_config_probe(name)
79            .or(pkg_config_probe(&format!("lib{}", name.to_lowercase())))
80            .or(dependency.library.as_ref().and_then(|lib_name| {
81                let lib_name = lib_name.to_string_lossy().to_string();
82                let lib_name_without_ext = lib_name.split('.').next().unwrap_or(&lib_name);
83                pkg_config_probe(lib_name_without_ext)
84                    .or(pkg_config_probe(&format!("lib{lib_name_without_ext}")))
85            }));
86        if let Some(info) = lib_info {
87            let include_dir = if let Some(header) = &dependency.header {
88                Some(
89                    info.include_paths
90                        .iter()
91                        .find(|path| path.join(header).exists())
92                        .ok_or(ExternalDependencyError::SuccessfulProbeHeaderNotFound(
93                            name.to_string(),
94                            header.to_slash_lossy().to_string(),
95                        ))?
96                        .clone(),
97                )
98            } else {
99                info.include_paths.first().cloned()
100            };
101            let lib_dir = if let Some(lib) = &dependency.library {
102                info.link_paths
103                    .iter()
104                    .find(|path| library_exists(path, lib, &config.lib_patterns))
105                    .cloned()
106                    .or(info.link_paths.first().cloned())
107            } else {
108                info.link_paths.first().cloned()
109            };
110            let bin_dir = lib_dir.as_ref().and_then(|lib_dir| {
111                lib_dir
112                    .parent()
113                    .map(|parent| parent.join("bin"))
114                    .filter(|dir| dir.is_dir())
115            });
116            let lib_name = lib_dir.as_ref().and_then(|lib_dir| {
117                let prefix = dependency
118                    .library
119                    .as_ref()
120                    .map(|lib_name| lib_name.to_string_lossy().to_string())
121                    .unwrap_or(name.to_lowercase());
122                get_lib_name(lib_dir, &prefix)
123            });
124            return Ok(ExternalDependencyInfo {
125                include_dir,
126                lib_dir,
127                bin_dir,
128                lib_name,
129                lib_info: Some(info),
130            });
131        }
132        Self::fallback_probe(name, dependency, config)
133    }
134
135    #[tracing::instrument(level = "trace", skip(config))]
136    fn fallback_probe(
137        name: &str,
138        dependency: &ExternalDependencySpec,
139        config: &ExternalDependencySearchConfig,
140    ) -> Result<Self, ExternalDependencyError> {
141        let env_prefix = std::env::var(format!("{}_DIR", name.to_uppercase())).ok();
142
143        let mut search_prefixes = Vec::new();
144        if let Some(dir) = env_prefix {
145            search_prefixes.push(PathBuf::from(dir));
146        }
147        if let Some(prefix) = config.prefixes.get(&format!("{}_DIR", name.to_uppercase())) {
148            search_prefixes.push(prefix.clone());
149        }
150        search_prefixes.extend(config.search_prefixes.iter().cloned());
151
152        let mut include_dir = get_incdir(name, config);
153
154        if let Some(header) = &dependency.header {
155            if !&include_dir
156                .as_ref()
157                .is_some_and(|inc_dir| inc_dir.join(header).exists())
158            {
159                // Search prefixes
160                let inc_dir = search_prefixes
161                    .iter()
162                    .find_map(|prefix| {
163                        let inc_dir = prefix.join(&config.include_subdir);
164                        if inc_dir.join(header).exists() {
165                            Some(inc_dir)
166                        } else {
167                            None
168                        }
169                    })
170                    .ok_or(ExternalDependencyError::HeaderNotFound(
171                        name.to_string(),
172                        header.to_slash_lossy().to_string(),
173                    ))?;
174                include_dir = Some(inc_dir);
175            }
176        }
177
178        let mut lib_dir = get_libdir(name, config);
179
180        if let Some(lib) = &dependency.library {
181            if !lib_dir
182                .as_ref()
183                .is_some_and(|lib_dir| library_exists(lib_dir, lib, &config.lib_patterns))
184            {
185                let probed_lib_dir = search_prefixes
186                    .iter()
187                    .find_map(|prefix| {
188                        for lib_subdir in &config.lib_subdirs {
189                            let lib_dir_candidate = prefix.join(lib_subdir);
190                            if library_exists(&lib_dir_candidate, lib, &config.lib_patterns) {
191                                return Some(lib_dir_candidate);
192                            }
193                        }
194                        None
195                    })
196                    .ok_or(ExternalDependencyError::LibraryNotFound(
197                        name.to_string(),
198                        lib.to_slash_lossy().to_string(),
199                    ))?;
200                lib_dir = Some(probed_lib_dir);
201            }
202        }
203
204        if let (None, None) = (&include_dir, &lib_dir) {
205            return Err(ExternalDependencyError::NotFound(name.into()));
206        }
207        let bin_dir = lib_dir.as_ref().and_then(|lib_dir| {
208            lib_dir
209                .parent()
210                .map(|parent| parent.join("bin"))
211                .filter(|dir| dir.is_dir())
212        });
213        let lib_name = lib_dir.as_ref().and_then(|lib_dir| {
214            let prefix = dependency
215                .library
216                .as_ref()
217                .map(|lib_name| lib_name.to_string_lossy().to_string())
218                .unwrap_or(name.to_lowercase());
219            get_lib_name(lib_dir, &prefix)
220        });
221        Ok(ExternalDependencyInfo {
222            include_dir,
223            lib_dir,
224            bin_dir,
225            lib_name,
226            lib_info: None,
227        })
228    }
229
230    pub(crate) fn define_flags(&self) -> Vec<String> {
231        if let Some(info) = &self.lib_info {
232            info.defines
233                .iter()
234                .map(|(k, v)| match v {
235                    Some(val) => {
236                        format!("-D{k}={val}")
237                    }
238                    None => format!("-D{k}"),
239                })
240                .collect_vec()
241        } else {
242            Vec::new()
243        }
244    }
245
246    pub(crate) fn lib_link_args(&self, compiler: &cc::Tool) -> Vec<String> {
247        if let Some(info) = &self.lib_info {
248            info.link_paths
249                .iter()
250                .map(|p| lib_dir_compile_arg(p, compiler))
251                .chain(
252                    info.libs
253                        .iter()
254                        .map(|lib| format_lib_link_arg(lib, compiler)),
255                )
256                .chain(info.ld_args.iter().map(|ld_arg_group| {
257                    ld_arg_group
258                        .iter()
259                        .map(|arg| format_linker_arg(arg, compiler))
260                        .collect::<Vec<_>>()
261                        .join(" ")
262                }))
263                .collect_vec()
264        } else {
265            self.lib_dir
266                .iter()
267                .map(|lib_dir| lib_dir_compile_arg(lib_dir, compiler))
268                .chain(
269                    self.lib_name
270                        .as_ref()
271                        .and_then(|lib_name| {
272                            if compiler.is_like_msvc() {
273                                self.lib_dir.as_ref().map(|lib_dir| {
274                                    lib_dir.join(lib_name).to_slash_lossy().to_string()
275                                })
276                            } else {
277                                Some(format!("-l{lib_name}"))
278                            }
279                        })
280                        .iter()
281                        .cloned(),
282                )
283                .collect_vec()
284        }
285    }
286}
287
288impl HasVariables for HashMap<String, ExternalDependencyInfo> {
289    #[tracing::instrument(level = "trace")]
290    fn get_variable(&self, input: &str) -> Result<Option<String>, GetVariableError> {
291        Ok(input.split_once('_').and_then(|(dep_key, dep_dir_type)| {
292            self.get(dep_key)
293                .and_then(|dep| match dep_dir_type {
294                    "DIR" => dep
295                        .include_dir
296                        .as_ref()
297                        .and_then(|dir| dir.parent().map(|parent| parent.to_path_buf())),
298                    "INCDIR" => dep.include_dir.clone(),
299                    "LIBDIR" => dep.lib_dir.clone(),
300                    "BINDIR" => dep.bin_dir.clone(),
301                    _ => None,
302                })
303                .as_deref()
304                .map(format_path)
305        }))
306    }
307}
308
309fn library_exists(lib_dir: &Path, lib: &Path, patterns: &[String]) -> bool {
310    patterns.iter().any(|pattern| {
311        let file_name = pattern.replace('?', &format!("{}", lib.display()));
312        lib_dir.join(&file_name).exists()
313    })
314}
315
316fn get_incdir(name: &str, config: &ExternalDependencySearchConfig) -> Option<PathBuf> {
317    let var_name = format!("{}_INCDIR", name.to_uppercase());
318    if let Ok(env_incdir) = std::env::var(&var_name) {
319        Some(env_incdir.into())
320    } else {
321        config.prefixes.get(&var_name).cloned()
322    }
323    .filter(|dir| dir.is_dir())
324}
325
326fn get_libdir(name: &str, config: &ExternalDependencySearchConfig) -> Option<PathBuf> {
327    let var_name = format!("{}_LIBDIR", name.to_uppercase());
328    if let Ok(env_incdir) = std::env::var(&var_name) {
329        Some(env_incdir.into())
330    } else {
331        config.prefixes.get(&var_name).cloned()
332    }
333    .filter(|dir| dir.is_dir())
334}
335
336fn not_found_help(name: &str) -> String {
337    let env_dir = format!("{}_DIR", name.to_uppercase());
338    let env_inc = format!("{}_INCDIR", name.to_uppercase());
339    let env_lib = format!("{}_LIBDIR", name.to_uppercase());
340
341    format!(
342        r#"run `lx debug toolchains` to check pkg-config availability, or set:
343- {env_dir} for the installation prefix, or
344- {env_inc} and {env_lib} for specific directories
345
346alternatively, add to the [external_dependencies] section in lux.toml:
347[external_dependencies.{name}]
348prefix = "/path/to/installation""#
349    )
350}
351
352fn lib_dir_compile_arg(dir: &Path, compiler: &cc::Tool) -> String {
353    if compiler.is_like_msvc() {
354        format!("/LIBPATH:{}", dir.to_slash_lossy())
355    } else {
356        format!("-L{}", dir.to_slash_lossy())
357    }
358}
359
360fn format_lib_link_arg(lib: &str, compiler: &cc::Tool) -> String {
361    if compiler.is_like_msvc() {
362        format!("{lib}.lib")
363    } else {
364        format!("-l{lib}")
365    }
366}
367
368fn format_linker_arg(arg: &str, compiler: &cc::Tool) -> String {
369    if compiler.is_like_msvc() {
370        format!("-Wl,{arg}")
371    } else {
372        format!("/link {arg}")
373    }
374}
375
376pub(crate) fn to_lib_name(file: &Path) -> String {
377    let file_name = file.file_name().unwrap_or_default();
378    if cfg!(target_family = "unix") {
379        file_name
380            .to_string_lossy()
381            .trim_start_matches("lib")
382            .trim_end_matches(".a")
383            .to_string()
384    } else {
385        file_name.to_string_lossy().to_string()
386    }
387}
388
389fn get_lib_name(lib_dir: &Path, prefix: &str) -> Option<String> {
390    fs::sync::read_dir(lib_dir)
391        .ok()
392        .and_then(|entries| {
393            entries
394                .filter_map(Result::ok)
395                .map(|entry| entry.path().to_path_buf())
396                .filter(|file| file.extension().is_some_and(|ext| ext == c_lib_extension()))
397                .filter(|file| {
398                    file.file_name()
399                        .is_some_and(|name| is_lib_name(&name.to_string_lossy(), prefix))
400                })
401                .collect_vec()
402                .first()
403                .cloned()
404        })
405        .map(|file| to_lib_name(&file))
406}
407
408fn is_lib_name(file_name: &str, prefix: &str) -> bool {
409    #[cfg(target_family = "unix")]
410    let file_name = file_name.trim_start_matches("lib");
411    file_name == format!("{}.{}", prefix, c_lib_extension())
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use assert_fs::{prelude::*, TempDir};
418
419    #[tokio::test]
420    async fn test_detect_zlib_pkg_config_header() {
421        // requires zlib to be in the nativeCheckInputs or dev environment
422        let config = ExternalDependencySearchConfig::default();
423        ExternalDependencyInfo::probe(
424            "zlib",
425            &ExternalDependencySpec {
426                header: Some("zlib.h".into()),
427                library: None,
428            },
429            &config,
430        )
431        .unwrap();
432    }
433
434    #[tokio::test]
435    async fn test_detect_zlib_pkg_config_library_libz() {
436        // requires zlib to be in the nativeCheckInputs or dev environment
437        let config = ExternalDependencySearchConfig::default();
438        ExternalDependencyInfo::probe(
439            "zlib",
440            &ExternalDependencySpec {
441                library: Some("libz".into()),
442                header: None,
443            },
444            &config,
445        )
446        .unwrap();
447    }
448
449    #[tokio::test]
450    async fn test_detect_zlib_pkg_config_library_z() {
451        // requires zlib to be in the nativeCheckInputs or dev environment
452        let config = ExternalDependencySearchConfig::default();
453        ExternalDependencyInfo::probe(
454            "zlib",
455            &ExternalDependencySpec {
456                library: Some("z".into()),
457                header: None,
458            },
459            &config,
460        )
461        .unwrap();
462    }
463
464    #[tokio::test]
465    async fn test_detect_zlib_pkg_config_library_zlib() {
466        // requires zlib to be in the nativeCheckInputs or dev environment
467        let config = ExternalDependencySearchConfig::default();
468        ExternalDependencyInfo::probe(
469            "zlib",
470            &ExternalDependencySpec {
471                library: Some("zlib".into()),
472                header: None,
473            },
474            &config,
475        )
476        .unwrap();
477    }
478
479    #[tokio::test]
480    async fn test_fallback_detect_header_prefix() {
481        let temp = TempDir::new().unwrap();
482        let prefix_dir = temp.child("usr");
483        let include_dir = prefix_dir.child("include");
484        include_dir.create_dir_all().unwrap();
485
486        let header = include_dir.child("foo.h");
487        header.touch().unwrap();
488
489        let mut config = ExternalDependencySearchConfig::default();
490        config
491            .prefixes
492            .insert("FOO_DIR".into(), prefix_dir.path().to_path_buf());
493
494        ExternalDependencyInfo::fallback_probe(
495            "foo",
496            &ExternalDependencySpec {
497                header: Some("foo.h".into()),
498                library: None,
499            },
500            &config,
501        )
502        .unwrap();
503    }
504
505    #[tokio::test]
506    async fn test_fallback_detect_header_prefix_incdir() {
507        let temp = TempDir::new().unwrap();
508        let include_dir = temp.child("include");
509        include_dir.create_dir_all().unwrap();
510
511        let header = include_dir.child("foo.h");
512        header.touch().unwrap();
513
514        let mut config = ExternalDependencySearchConfig::default();
515        config
516            .prefixes
517            .insert("FOO_INCDIR".into(), include_dir.path().to_path_buf());
518
519        ExternalDependencyInfo::fallback_probe(
520            "foo",
521            &ExternalDependencySpec {
522                header: Some("foo.h".into()),
523                library: None,
524            },
525            &config,
526        )
527        .unwrap();
528    }
529
530    #[tokio::test]
531    async fn test_fallback_detect_library_prefix() {
532        let temp = TempDir::new().unwrap();
533        let prefix_dir = temp.child("usr");
534        let include_dir = prefix_dir.child("include");
535        let lib_dir = prefix_dir.child("lib");
536        include_dir.create_dir_all().unwrap();
537        lib_dir.create_dir_all().unwrap();
538
539        #[cfg(any(target_os = "linux", target_os = "android"))]
540        let lib = lib_dir.child("libfoo.so");
541        #[cfg(target_os = "macos")]
542        let lib = lib_dir.child("libfoo.dylib");
543        #[cfg(target_family = "windows")]
544        let lib = lib_dir.child("foo.dll");
545
546        lib.touch().unwrap();
547
548        let mut config = ExternalDependencySearchConfig::default();
549        config
550            .prefixes
551            .insert("FOO_DIR".to_string(), prefix_dir.path().to_path_buf());
552
553        ExternalDependencyInfo::fallback_probe(
554            "foo",
555            &ExternalDependencySpec {
556                library: Some("foo".into()),
557                header: None,
558            },
559            &config,
560        )
561        .unwrap();
562    }
563
564    #[tokio::test]
565    async fn test_fallback_detect_library_dirs() {
566        let temp = TempDir::new().unwrap();
567
568        let include_dir = temp.child("include");
569        include_dir.create_dir_all().unwrap();
570
571        let lib_dir = temp.child("lib");
572        lib_dir.create_dir_all().unwrap();
573
574        #[cfg(any(target_os = "linux", target_os = "android"))]
575        let lib = lib_dir.child("libfoo.so");
576        #[cfg(target_os = "macos")]
577        let lib = lib_dir.child("libfoo.dylib");
578        #[cfg(target_family = "windows")]
579        let lib = lib_dir.child("foo.dll");
580
581        lib.touch().unwrap();
582
583        let mut config = ExternalDependencySearchConfig::default();
584        config
585            .prefixes
586            .insert("FOO_INCDIR".into(), include_dir.path().to_path_buf());
587        config
588            .prefixes
589            .insert("FOO_LIBDIR".into(), lib_dir.path().to_path_buf());
590
591        ExternalDependencyInfo::fallback_probe(
592            "foo",
593            &ExternalDependencySpec {
594                library: Some("foo".into()),
595                header: None,
596            },
597            &config,
598        )
599        .unwrap();
600    }
601
602    #[tokio::test]
603    async fn test_fallback_detect_search_prefixes() {
604        let temp = TempDir::new().unwrap();
605        let prefix_dir = temp.child("usr");
606        let include_dir = prefix_dir.child("include");
607        let lib_dir = prefix_dir.child("lib");
608        include_dir.create_dir_all().unwrap();
609        lib_dir.create_dir_all().unwrap();
610
611        #[cfg(any(target_os = "linux", target_os = "android"))]
612        let lib = lib_dir.child("libfoo.so");
613        #[cfg(target_os = "macos")]
614        let lib = lib_dir.child("libfoo.dylib");
615        #[cfg(target_family = "windows")]
616        let lib = lib_dir.child("foo.dll");
617
618        lib.touch().unwrap();
619
620        let mut config = ExternalDependencySearchConfig::default();
621        config.search_prefixes.push(prefix_dir.path().to_path_buf());
622
623        ExternalDependencyInfo::fallback_probe(
624            "foo",
625            &ExternalDependencySpec {
626                library: Some("foo".into()),
627                header: None,
628            },
629            &config,
630        )
631        .unwrap();
632    }
633
634    #[tokio::test]
635    async fn test_fallback_detect_not_found() {
636        let config = ExternalDependencySearchConfig::default();
637
638        let result = ExternalDependencyInfo::fallback_probe(
639            "foo",
640            &ExternalDependencySpec {
641                header: Some("foo.h".into()),
642                library: None,
643            },
644            &config,
645        );
646
647        assert!(matches!(
648            result,
649            Err(ExternalDependencyError::HeaderNotFound { .. })
650        ));
651    }
652
653    #[cfg(not(target_env = "msvc"))]
654    #[tokio::test]
655    async fn test_to_lib_name() {
656        assert_eq!(to_lib_name(&PathBuf::from("lua.a")), "lua".to_string());
657        assert_eq!(
658            to_lib_name(&PathBuf::from("lua-5.1.a")),
659            "lua-5.1".to_string()
660        );
661        assert_eq!(
662            to_lib_name(&PathBuf::from("lua5.1.a")),
663            "lua5.1".to_string()
664        );
665        assert_eq!(to_lib_name(&PathBuf::from("lua51.a")), "lua51".to_string());
666        assert_eq!(
667            to_lib_name(&PathBuf::from("luajit-5.2.a")),
668            "luajit-5.2".to_string()
669        );
670        assert_eq!(
671            to_lib_name(&PathBuf::from("lua-5.2.a")),
672            "lua-5.2".to_string()
673        );
674        assert_eq!(to_lib_name(&PathBuf::from("liblua.a")), "lua".to_string());
675        assert_eq!(
676            to_lib_name(&PathBuf::from("liblua-5.1.a")),
677            "lua-5.1".to_string()
678        );
679        assert_eq!(
680            to_lib_name(&PathBuf::from("liblua53.a")),
681            "lua53".to_string()
682        );
683        assert_eq!(
684            to_lib_name(&PathBuf::from("liblua-54.a")),
685            "lua-54".to_string()
686        );
687    }
688}