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