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