Skip to main content

pine_core/
library.rs

1//! Resolving `import`ed libraries to their source.
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7/// Loads the source of a library by its import path.
8pub trait LibraryLoader {
9    fn load_library(&self, path: &str) -> Result<String, String>;
10}
11
12/// A [`LibraryLoader`] backed by an in-memory set of named sources: register a
13/// library's text under a path and `import <path>` resolves against it.
14#[derive(Debug, Default, Clone)]
15pub struct FileResolver {
16    files: HashMap<String, String>,
17}
18
19impl FileResolver {
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Register `source` under `path` in place.
25    pub fn add(&mut self, path: &str, source: &str) {
26        self.files.insert(path.to_string(), source.to_string());
27    }
28
29    /// Register `source` under `path`, returning self for chaining.
30    pub fn with_file(mut self, path: &str, source: &str) -> Self {
31        self.add(path, source);
32        self
33    }
34}
35
36impl LibraryLoader for FileResolver {
37    fn load_library(&self, path: &str) -> Result<String, String> {
38        self.files
39            .get(path)
40            .cloned()
41            .ok_or_else(|| format!("no library registered for '{path}'"))
42    }
43}
44
45/// A [`LibraryLoader`] that reads libraries from directories on disk.
46pub struct DirLoader {
47    roots: Vec<PathBuf>,
48    loaded: RefCell<HashMap<String, String>>,
49}
50
51impl DirLoader {
52    pub fn new(roots: Vec<PathBuf>) -> Self {
53        Self {
54            roots,
55            loaded: RefCell::new(HashMap::new()),
56        }
57    }
58
59    /// The source of a library this loader already resolved, if any. Keyed by
60    /// import path — the same string sema attributes its diagnostics to.
61    pub fn source_of(&self, path: &str) -> Option<String> {
62        self.loaded.borrow().get(path).cloned()
63    }
64
65    /// Every file `path` could name, most specific first.
66    fn candidates(&self, path: &str) -> Vec<PathBuf> {
67        let trimmed = path.trim_matches('/');
68        let parts: Vec<&str> = trimmed.split('/').collect();
69
70        let mut relative = vec![
71            PathBuf::from(format!("{trimmed}.pine")),
72            PathBuf::from(trimmed),
73        ];
74        // `<user>/<Name>/<version>`: also accept the library dropped in without
75        // its version, or without its namespace entirely.
76        if let [user, name, _version] = parts.as_slice() {
77            relative.push(PathBuf::from(user).join(format!("{name}.pine")));
78            relative.push(PathBuf::from(format!("{name}.pine")));
79        }
80
81        self.roots
82            .iter()
83            .flat_map(|root| relative.iter().map(|rel| root.join(rel)))
84            .collect()
85    }
86}
87
88impl LibraryLoader for DirLoader {
89    fn load_library(&self, path: &str) -> Result<String, String> {
90        if let Some(cached) = self.source_of(path) {
91            return Ok(cached);
92        }
93
94        for candidate in self.candidates(path) {
95            if !candidate.is_file() {
96                continue;
97            }
98            let source = std::fs::read_to_string(&candidate)
99                .map_err(|e| format!("{}: {e}", candidate.display()))?;
100            self.loaded
101                .borrow_mut()
102                .insert(path.to_string(), source.clone());
103            return Ok(source);
104        }
105
106        if self.roots.is_empty() {
107            return Err("no library directory given (pass --lib <DIR>)".to_string());
108        }
109        Err(format!(
110            "not found under {}",
111            self.roots
112                .iter()
113                .map(|r| r.display().to_string())
114                .collect::<Vec<_>>()
115                .join(", ")
116        ))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use std::sync::atomic::{AtomicU32, Ordering};
124
125    /// A unique temp directory that removes itself on drop.
126    struct TempDir(PathBuf);
127
128    impl TempDir {
129        fn new() -> Self {
130            static COUNTER: AtomicU32 = AtomicU32::new(0);
131            let id = COUNTER.fetch_add(1, Ordering::Relaxed);
132            let dir =
133                std::env::temp_dir().join(format!("pine-dirloader-{}-{id}", std::process::id()));
134            std::fs::create_dir_all(&dir).unwrap();
135            TempDir(dir)
136        }
137
138        fn write(&self, rel: &str, contents: &str) {
139            let path = self.0.join(rel);
140            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
141            std::fs::write(path, contents).unwrap();
142        }
143    }
144
145    impl Drop for TempDir {
146        fn drop(&mut self) {
147            let _ = std::fs::remove_dir_all(&self.0);
148        }
149    }
150
151    #[test]
152    fn loads_a_pine_file_and_caches_it() {
153        let dir = TempDir::new();
154        dir.write("mylib.pine", "// lib source");
155        let loader = DirLoader::new(vec![dir.0.clone()]);
156
157        assert_eq!(loader.load_library("mylib").unwrap(), "// lib source");
158        // A resolved source is cached under the import path.
159        assert_eq!(loader.source_of("mylib").as_deref(), Some("// lib source"));
160    }
161
162    #[test]
163    fn a_versioned_path_falls_back_to_the_bare_name() {
164        let dir = TempDir::new();
165        dir.write("Stats.pine", "// stats");
166        let loader = DirLoader::new(vec![dir.0.clone()]);
167
168        // `<user>/<Name>/<version>` also resolves against a bare `<Name>.pine`.
169        assert_eq!(loader.load_library("alice/Stats/3").unwrap(), "// stats");
170    }
171}