Skip to main content

lust/packages/
mod.rs

1use crate::embed::native_types::ModuleStub;
2use crate::{NativeExport, VM};
3use dirs::home_dir;
4use libloading::Library;
5use serde::Deserialize;
6use std::{
7    collections::{BTreeMap, HashMap},
8    env,
9    ffi::OsStr,
10    fs, io,
11    path::{Path, PathBuf},
12    process::{Command, ExitStatus, Stdio},
13    sync::{Mutex, OnceLock},
14};
15use thiserror::Error;
16
17pub mod archive;
18pub mod credentials;
19pub mod dependencies;
20pub mod manifest;
21pub mod registry;
22
23pub use archive::{build_package_archive, ArchiveError, PackageArchive};
24pub use credentials::{
25    clear_credentials, credentials_file, load_credentials, save_credentials, Credentials,
26    CredentialsError,
27};
28pub use dependencies::{
29    resolve_dependencies, DependencyResolution, DependencyResolutionError, ResolvedLuaDependency,
30    ResolvedLustDependency, ResolvedRustDependency,
31};
32pub use manifest::{ManifestError, ManifestKind, PackageManifest, PackageSection};
33pub use registry::{
34    DownloadedArchive, PackageDetails, PackageSearchResponse, PackageSummary, PackageVersionInfo,
35    PublishResponse, RegistryClient, RegistryError, SearchParameters, DEFAULT_BASE_URL,
36};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum PackageKind {
40    LustLibrary,
41    RustExtension,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct PackageSpecifier {
46    pub name: String,
47    pub version: Option<String>,
48    pub kind: PackageKind,
49}
50
51impl PackageSpecifier {
52    pub fn new(name: impl Into<String>, kind: PackageKind) -> Self {
53        Self {
54            name: name.into(),
55            version: None,
56            kind,
57        }
58    }
59
60    pub fn with_version(mut self, version: impl Into<String>) -> Self {
61        self.version = Some(version.into());
62        self
63    }
64}
65
66pub struct PackageManager {
67    root: PathBuf,
68}
69
70impl PackageManager {
71    pub fn new(root: impl Into<PathBuf>) -> Self {
72        Self { root: root.into() }
73    }
74
75    pub fn default_root() -> PathBuf {
76        let mut base = home_dir().unwrap_or_else(|| PathBuf::from("."));
77        base.push(".lust");
78        base.push("packages");
79        base
80    }
81
82    pub fn root(&self) -> &Path {
83        &self.root
84    }
85
86    pub fn ensure_layout(&self) -> io::Result<()> {
87        fs::create_dir_all(&self.root)
88    }
89}
90
91#[derive(Debug, Error)]
92pub enum LocalModuleError {
93    #[error("I/O error: {0}")]
94    Io(#[from] io::Error),
95
96    #[error("failed to parse Cargo manifest {path}: {source}")]
97    Manifest {
98        path: PathBuf,
99        #[source]
100        source: toml::de::Error,
101    },
102
103    #[error("cargo build failed for '{module}' with status {status}: {output}")]
104    CargoBuild {
105        module: String,
106        status: ExitStatus,
107        output: String,
108    },
109
110    #[error("built library not found at {0}")]
111    LibraryMissing(PathBuf),
112
113    #[error("failed to load dynamic library: {0}")]
114    LibraryLoad(#[from] libloading::Error),
115
116    #[error("register function 'lust_extension_register' missing in {0}")]
117    RegisterSymbolMissing(String),
118
119    #[error("register function reported failure in {0}")]
120    RegisterFailed(String),
121}
122
123#[derive(Debug, Clone)]
124pub struct LocalBuildOutput {
125    pub name: String,
126    pub library_path: PathBuf,
127}
128
129#[derive(Debug)]
130pub struct LoadedRustModule {
131    name: String,
132}
133
134impl LoadedRustModule {
135    pub fn name(&self) -> &str {
136        &self.name
137    }
138}
139
140#[derive(Debug, Clone)]
141pub struct StubFile {
142    pub relative_path: PathBuf,
143    pub contents: String,
144}
145
146#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
147#[derive(Debug, Clone)]
148pub struct PreparedRustDependency {
149    pub dependency: ResolvedRustDependency,
150    pub build: LocalBuildOutput,
151    pub stub_roots: Vec<StubRoot>,
152}
153
154#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
155#[derive(Debug, Clone)]
156pub struct StubRoot {
157    pub prefix: String,
158    pub directory: PathBuf,
159}
160
161#[derive(Debug, Clone)]
162pub struct BuildOptions<'a> {
163    pub features: &'a [String],
164    pub default_features: bool,
165}
166
167impl<'a> Default for BuildOptions<'a> {
168    fn default() -> Self {
169        Self {
170            features: &[],
171            default_features: true,
172        }
173    }
174}
175
176pub fn build_local_module(
177    module_dir: &Path,
178    options: BuildOptions<'_>,
179) -> Result<LocalBuildOutput, LocalModuleError> {
180    let crate_name = read_crate_name(module_dir)?;
181    let profile = extension_profile();
182    let mut command = Command::new("cargo");
183    command.arg("build");
184    command.arg("--quiet");
185    match profile.as_str() {
186        "release" => {
187            command.arg("--release");
188        }
189        "debug" => {}
190        other => {
191            command.args(["--profile", other]);
192        }
193    }
194    if !options.default_features {
195        command.arg("--no-default-features");
196    }
197    if !options.features.is_empty() {
198        command.arg("--features");
199        command.arg(options.features.join(","));
200    }
201    command.current_dir(module_dir);
202    command.stdout(Stdio::piped()).stderr(Stdio::piped());
203    let output = command.output()?;
204    if !output.status.success() {
205        let mut message = String::new();
206        if !output.stdout.is_empty() {
207            message.push_str(&String::from_utf8_lossy(&output.stdout));
208        }
209        if !output.stderr.is_empty() {
210            if !message.is_empty() {
211                message.push('\n');
212            }
213            message.push_str(&String::from_utf8_lossy(&output.stderr));
214        }
215        return Err(LocalModuleError::CargoBuild {
216            module: crate_name,
217            status: output.status,
218            output: message,
219        });
220    }
221
222    let artifact = module_dir
223        .join("target")
224        .join(&profile)
225        .join(library_file_name(&crate_name));
226    if !artifact.exists() {
227        return Err(LocalModuleError::LibraryMissing(artifact));
228    }
229
230    Ok(LocalBuildOutput {
231        name: crate_name,
232        library_path: artifact,
233    })
234}
235
236pub fn load_local_module(
237    vm: &mut VM,
238    build: &LocalBuildOutput,
239) -> Result<LoadedRustModule, LocalModuleError> {
240    let library = get_or_load_library(&build.library_path)?;
241    unsafe {
242        let register = library
243            .get::<unsafe extern "C" fn(*mut VM) -> bool>(b"lust_extension_register\0")
244            .map_err(|_| LocalModuleError::RegisterSymbolMissing(build.name.clone()))?;
245        vm.push_export_prefix(&build.name);
246        let success = register(vm as *mut VM);
247        vm.pop_export_prefix();
248        if !success {
249            return Err(LocalModuleError::RegisterFailed(build.name.clone()));
250        }
251    }
252    Ok(LoadedRustModule {
253        name: build.name.clone(),
254    })
255}
256
257pub fn collect_stub_files(
258    module_dir: &Path,
259    override_dir: Option<&Path>,
260) -> Result<Vec<StubFile>, LocalModuleError> {
261    let base_dir = match override_dir {
262        Some(dir) => dir.to_path_buf(),
263        None => module_dir.join("externs"),
264    };
265    if !base_dir.exists() {
266        return Ok(Vec::new());
267    }
268
269    let mut stubs = Vec::new();
270    visit_stub_dir(&base_dir, PathBuf::new(), &mut stubs)?;
271    Ok(stubs)
272}
273
274pub fn write_stub_files(
275    crate_name: &str,
276    stubs: &[StubFile],
277    output_root: &Path,
278) -> Result<Vec<PathBuf>, LocalModuleError> {
279    let mut written = Vec::new();
280    for stub in stubs {
281        let mut relative = if stub.relative_path.components().next().is_some() {
282            stub.relative_path.clone()
283        } else {
284            let mut path = PathBuf::new();
285            path.push(sanitized_crate_name(crate_name));
286            path.set_extension("lust");
287            path
288        };
289        if relative.extension().is_none() {
290            relative.set_extension("lust");
291        }
292        let destination = output_root.join(&relative);
293        if let Some(parent) = destination.parent() {
294            fs::create_dir_all(parent)?;
295        }
296        fs::write(&destination, &stub.contents)?;
297        written.push(relative);
298    }
299
300    Ok(written)
301}
302
303#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
304pub fn collect_rust_dependency_artifacts(
305    dep: &ResolvedRustDependency,
306) -> Result<(LocalBuildOutput, Vec<StubFile>), String> {
307    let build = build_local_module(
308        &dep.crate_dir,
309        BuildOptions {
310            features: &dep.features,
311            default_features: dep.default_features,
312        },
313    )
314    .map_err(|err| format!("{}: {}", dep.crate_dir.display(), err))?;
315
316    let mut preview_vm = VM::new();
317    let preview_module = load_local_module(&mut preview_vm, &build)
318        .map_err(|err| format!("{}: {}", dep.crate_dir.display(), err))?;
319    let exports = preview_vm.take_exported_natives();
320    let type_stubs = preview_vm.take_type_stubs();
321    preview_vm.clear_native_functions();
322    drop(preview_module);
323
324    let mut stubs = stub_files_from_exports(&exports, &type_stubs);
325    let manual_stubs = collect_stub_files(&dep.crate_dir, dep.externs_override.as_deref())
326        .map_err(|err| format!("{}: {}", dep.crate_dir.display(), err))?;
327    if !manual_stubs.is_empty() {
328        stubs.extend(manual_stubs);
329    }
330
331    Ok((build, stubs))
332}
333
334#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
335pub fn prepare_rust_dependencies(
336    deps: &DependencyResolution,
337    project_dir: &Path,
338) -> Result<Vec<PreparedRustDependency>, String> {
339    if deps.rust().is_empty() {
340        return Ok(Vec::new());
341    }
342
343    let mut prepared = Vec::new();
344    let mut project_extern_root: Option<PathBuf> = None;
345
346    for dep in deps.rust() {
347        let (build, stubs) = collect_rust_dependency_artifacts(dep)?;
348        let mut stub_roots = Vec::new();
349        let sanitized_prefix = sanitized_crate_name(&build.name);
350        let mut register_root = |dir: &Path| {
351            let dir_buf = dir.join(&sanitized_prefix);
352            if dir_buf.exists()
353                && !stub_roots
354                    .iter()
355                    .any(|root: &StubRoot| root.directory == dir_buf)
356            {
357                stub_roots.push(StubRoot {
358                    prefix: sanitized_prefix.clone(),
359                    directory: dir_buf,
360                });
361            }
362        };
363
364        let fallback_root = project_dir.join("externs");
365        register_root(&fallback_root);
366
367        if let Some(cache_dir) = &dep.cache_stub_dir {
368            fs::create_dir_all(cache_dir).map_err(|err| {
369                format!(
370                    "failed to create extern cache '{}': {}",
371                    cache_dir.display(),
372                    err
373                )
374            })?;
375            if !stubs.is_empty() {
376                write_stub_files(&build.name, &stubs, cache_dir)
377                    .map_err(|err| format!("{}: {}", cache_dir.display(), err))?;
378            }
379            if cache_dir.exists() {
380                register_root(cache_dir);
381            }
382        } else {
383            let root = project_extern_root
384                .get_or_insert_with(|| project_dir.join("externs"))
385                .clone();
386            if !stubs.is_empty() {
387                fs::create_dir_all(&root).map_err(|err| format!("{}: {}", root.display(), err))?;
388                write_stub_files(&build.name, &stubs, &root)
389                    .map_err(|err| format!("{}: {}", root.display(), err))?;
390                register_root(&root);
391            } else if root.exists() {
392                register_root(&root);
393            }
394        }
395
396        prepared.push(PreparedRustDependency {
397            dependency: dep.clone(),
398            build,
399            stub_roots,
400        });
401    }
402
403    Ok(prepared)
404}
405
406#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
407pub fn load_prepared_rust_dependencies(
408    prepared: &[PreparedRustDependency],
409    vm: &mut VM,
410) -> Result<Vec<LoadedRustModule>, String> {
411    let mut loaded = Vec::new();
412    for item in prepared {
413        let module = load_local_module(vm, &item.build)
414            .map_err(|err| format!("{}: {}", item.dependency.crate_dir.display(), err))?;
415        loaded.push(module);
416    }
417    Ok(loaded)
418}
419
420fn extension_profile() -> String {
421    env::var("LUST_EXTENSION_PROFILE").unwrap_or_else(|_| "release".to_string())
422}
423
424fn library_file_name(crate_name: &str) -> String {
425    let sanitized = sanitized_crate_name(crate_name);
426    #[cfg(target_os = "windows")]
427    {
428        format!("{sanitized}.dll")
429    }
430    #[cfg(target_os = "macos")]
431    {
432        format!("lib{sanitized}.dylib")
433    }
434    #[cfg(all(unix, not(target_os = "macos")))]
435    {
436        format!("lib{sanitized}.so")
437    }
438}
439
440fn sanitized_crate_name(name: &str) -> String {
441    name.replace('-', "_")
442}
443
444fn library_cache() -> &'static Mutex<HashMap<PathBuf, &'static Library>> {
445    static CACHE: OnceLock<Mutex<HashMap<PathBuf, &'static Library>>> = OnceLock::new();
446    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
447}
448
449fn get_or_load_library(path: &Path) -> Result<&'static Library, LocalModuleError> {
450    {
451        let cache = library_cache().lock().unwrap();
452        if let Some(lib) = cache.get(path) {
453            return Ok(*lib);
454        }
455    }
456
457    let library = unsafe { Library::new(path) }.map_err(LocalModuleError::LibraryLoad)?;
458    let leaked = Box::leak(Box::new(library));
459
460    let mut cache = library_cache().lock().unwrap();
461    let entry = cache.entry(path.to_path_buf()).or_insert(leaked);
462    Ok(*entry)
463}
464
465fn visit_stub_dir(
466    current: &Path,
467    relative: PathBuf,
468    stubs: &mut Vec<StubFile>,
469) -> Result<(), LocalModuleError> {
470    for entry in fs::read_dir(current)? {
471        let entry = entry?;
472        let path = entry.path();
473        let next_relative = relative.join(entry.file_name());
474        if path.is_dir() {
475            visit_stub_dir(&path, next_relative, stubs)?;
476        } else if path.extension() == Some(OsStr::new("lust")) {
477            let contents = fs::read_to_string(&path)?;
478            stubs.push(StubFile {
479                relative_path: next_relative,
480                contents,
481            });
482        }
483    }
484
485    Ok(())
486}
487
488pub fn stub_files_from_exports(
489    exports: &[NativeExport],
490    type_stubs: &[ModuleStub],
491) -> Vec<StubFile> {
492    if exports.is_empty() && type_stubs.iter().all(ModuleStub::is_empty) {
493        return Vec::new();
494    }
495
496    #[derive(Default)]
497    struct CombinedModule<'a> {
498        type_stub: ModuleStub,
499        functions: Vec<&'a NativeExport>,
500    }
501
502    let mut combined: BTreeMap<String, CombinedModule<'_>> = BTreeMap::new();
503    for stub in type_stubs {
504        if stub.is_empty() {
505            continue;
506        }
507        let entry = combined
508            .entry(stub.module.clone())
509            .or_insert_with(|| CombinedModule {
510                type_stub: ModuleStub {
511                    module: stub.module.clone(),
512                    ..ModuleStub::default()
513                },
514                ..CombinedModule::default()
515            });
516        entry.type_stub.struct_defs.extend(stub.struct_defs.clone());
517        entry.type_stub.enum_defs.extend(stub.enum_defs.clone());
518        entry.type_stub.trait_defs.extend(stub.trait_defs.clone());
519        entry.type_stub.const_defs.extend(stub.const_defs.clone());
520    }
521
522    for export in exports {
523        let (module, _) = match export.name().rsplit_once('.') {
524            Some(parts) => parts,
525            None => continue,
526        };
527        let entry = combined
528            .entry(module.to_string())
529            .or_insert_with(|| CombinedModule {
530                type_stub: ModuleStub {
531                    module: module.to_string(),
532                    ..ModuleStub::default()
533                },
534                ..CombinedModule::default()
535            });
536        entry.functions.push(export);
537    }
538
539    let mut result = Vec::new();
540    for (module, mut combined_entry) in combined {
541        combined_entry
542            .functions
543            .sort_by(|a, b| a.name().cmp(b.name()));
544        let mut contents = String::new();
545
546        let mut wrote_type = false;
547        let append_defs = |defs: &Vec<String>, contents: &mut String, wrote_flag: &mut bool| {
548            if defs.is_empty() {
549                return;
550            }
551            if *wrote_flag && !contents.ends_with("\n\n") && !contents.is_empty() {
552                contents.push('\n');
553            }
554            for def in defs {
555                contents.push_str(def);
556                if !def.ends_with('\n') {
557                    contents.push('\n');
558                }
559            }
560            *wrote_flag = true;
561        };
562
563        append_defs(
564            &combined_entry.type_stub.struct_defs,
565            &mut contents,
566            &mut wrote_type,
567        );
568        append_defs(
569            &combined_entry.type_stub.enum_defs,
570            &mut contents,
571            &mut wrote_type,
572        );
573        append_defs(
574            &combined_entry.type_stub.trait_defs,
575            &mut contents,
576            &mut wrote_type,
577        );
578        append_defs(
579            &combined_entry.type_stub.const_defs,
580            &mut contents,
581            &mut wrote_type,
582        );
583
584        if !combined_entry.functions.is_empty() {
585            if wrote_type && !contents.ends_with("\n\n") {
586                contents.push('\n');
587            }
588            contents.push_str("pub extern\n");
589            for export in combined_entry.functions {
590                if let Some((_, function)) = export.name().rsplit_once('.') {
591                    let params = format_params(export);
592                    let return_type = export.return_type();
593                    if let Some(doc) = export.doc() {
594                        contents.push_str("    -- ");
595                        contents.push_str(doc);
596                        if !doc.ends_with('\n') {
597                            contents.push('\n');
598                        }
599                    }
600                    contents.push_str("    function ");
601                    contents.push_str(function);
602                    contents.push('(');
603                    contents.push_str(&params);
604                    contents.push(')');
605                    if !return_type.trim().is_empty() && return_type.trim() != "()" {
606                        contents.push_str(": ");
607                        contents.push_str(return_type);
608                    }
609                    contents.push('\n');
610                }
611            }
612            contents.push_str("end\n");
613        }
614
615        if contents.is_empty() {
616            continue;
617        }
618        let mut relative = relative_stub_path(&module);
619        if relative.extension().is_none() {
620            relative.set_extension("lust");
621        }
622        result.push(StubFile {
623            relative_path: relative,
624            contents,
625        });
626    }
627
628    result
629}
630
631fn format_params(export: &NativeExport) -> String {
632    export
633        .params()
634        .iter()
635        .map(|param| {
636            let ty = param.ty().trim();
637            if ty.is_empty() {
638                "any"
639            } else {
640                ty
641            }
642        })
643        .collect::<Vec<_>>()
644        .join(", ")
645}
646
647fn relative_stub_path(module: &str) -> PathBuf {
648    let mut path = PathBuf::new();
649    let mut segments: Vec<String> = module.split('.').map(|seg| seg.replace('-', "_")).collect();
650    if let Some(first) = segments.first() {
651        if first == "externs" {
652            segments.remove(0);
653        }
654    }
655    if let Some(first) = segments.first() {
656        path.push(first);
657    }
658    if segments.len() > 1 {
659        for seg in &segments[1..segments.len() - 1] {
660            path.push(seg);
661        }
662        path.push(segments.last().unwrap());
663    } else if let Some(first) = segments.first() {
664        path.push(first);
665    }
666    path
667}
668
669fn read_crate_name(module_dir: &Path) -> Result<String, LocalModuleError> {
670    let manifest_path = module_dir.join("Cargo.toml");
671    let manifest_str = fs::read_to_string(&manifest_path)?;
672    #[derive(Deserialize)]
673    struct Manifest {
674        package: PackageSection,
675    }
676    #[derive(Deserialize)]
677    struct PackageSection {
678        name: String,
679    }
680    let manifest: Manifest =
681        toml::from_str(&manifest_str).map_err(|source| LocalModuleError::Manifest {
682            path: manifest_path,
683            source,
684        })?;
685    Ok(manifest.package.name)
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    #[test]
693    fn specifier_builder_sets_version() {
694        let spec = PackageSpecifier::new("foo", PackageKind::LustLibrary).with_version("1.2.3");
695        assert_eq!(spec.version.as_deref(), Some("1.2.3"));
696    }
697
698    #[test]
699    fn ensure_layout_creates_directories() {
700        let temp_dir = tempfile::tempdir().expect("temp directory");
701        let root = temp_dir.path().join("pkg");
702        let manager = PackageManager::new(&root);
703        manager.ensure_layout().expect("create dirs");
704        assert!(root.exists());
705        assert!(root.is_dir());
706    }
707
708    #[test]
709    fn library_name_sanitizes_hyphens() {
710        #[cfg(target_os = "windows")]
711        assert_eq!(library_file_name("my-ext"), "my_ext.dll");
712        #[cfg(target_os = "macos")]
713        assert_eq!(library_file_name("my-ext"), "libmy_ext.dylib");
714        #[cfg(all(unix, not(target_os = "macos")))]
715        assert_eq!(library_file_name("my-ext"), "libmy_ext.so");
716    }
717}