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