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