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    /// The on-disk file `path` resolves to, without reading it — for locating a
66    /// library file (e.g. editor go-to-definition).
67    pub fn resolve_path(&self, path: &str) -> Option<PathBuf> {
68        self.candidates(path).into_iter().find(|c| c.is_file())
69    }
70
71    /// Every file `path` could name, most specific first.
72    fn candidates(&self, path: &str) -> Vec<PathBuf> {
73        let trimmed = path.trim_matches('/');
74        let parts: Vec<&str> = trimmed.split('/').collect();
75
76        let mut relative = vec![
77            PathBuf::from(format!("{trimmed}.pine")),
78            PathBuf::from(trimmed),
79        ];
80        // `<user>/<Name>/<version>`: also accept the library dropped in without
81        // its version, or without its namespace entirely.
82        if let [user, name, _version] = parts.as_slice() {
83            relative.push(PathBuf::from(user).join(format!("{name}.pine")));
84            relative.push(PathBuf::from(format!("{name}.pine")));
85        }
86
87        self.roots
88            .iter()
89            .flat_map(|root| relative.iter().map(|rel| root.join(rel)))
90            .collect()
91    }
92}
93
94impl LibraryLoader for DirLoader {
95    fn load_library(&self, path: &str) -> Result<String, String> {
96        if let Some(cached) = self.source_of(path) {
97            return Ok(cached);
98        }
99
100        for candidate in self.candidates(path) {
101            if !candidate.is_file() {
102                continue;
103            }
104            let source = std::fs::read_to_string(&candidate)
105                .map_err(|e| format!("{}: {e}", candidate.display()))?;
106            self.loaded
107                .borrow_mut()
108                .insert(path.to_string(), source.clone());
109            return Ok(source);
110        }
111
112        if self.roots.is_empty() {
113            return Err("no library directory given (pass --lib <DIR>)".to_string());
114        }
115        Err(format!(
116            "not found under {}",
117            self.roots
118                .iter()
119                .map(|r| r.display().to_string())
120                .collect::<Vec<_>>()
121                .join(", ")
122        ))
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use std::sync::atomic::{AtomicU32, Ordering};
130
131    /// A unique temp directory that removes itself on drop.
132    struct TempDir(PathBuf);
133
134    impl TempDir {
135        fn new() -> Self {
136            static COUNTER: AtomicU32 = AtomicU32::new(0);
137            let id = COUNTER.fetch_add(1, Ordering::Relaxed);
138            let dir =
139                std::env::temp_dir().join(format!("pine-dirloader-{}-{id}", std::process::id()));
140            std::fs::create_dir_all(&dir).unwrap();
141            TempDir(dir)
142        }
143
144        fn write(&self, rel: &str, contents: &str) {
145            let path = self.0.join(rel);
146            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
147            std::fs::write(path, contents).unwrap();
148        }
149    }
150
151    impl Drop for TempDir {
152        fn drop(&mut self) {
153            let _ = std::fs::remove_dir_all(&self.0);
154        }
155    }
156
157    #[test]
158    fn loads_a_pine_file_and_caches_it() {
159        let dir = TempDir::new();
160        dir.write("mylib.pine", "// lib source");
161        let loader = DirLoader::new(vec![dir.0.clone()]);
162
163        assert_eq!(loader.load_library("mylib").unwrap(), "// lib source");
164        // A resolved source is cached under the import path.
165        assert_eq!(loader.source_of("mylib").as_deref(), Some("// lib source"));
166    }
167
168    #[test]
169    fn a_versioned_path_falls_back_to_the_bare_name() {
170        let dir = TempDir::new();
171        dir.write("Stats.pine", "// stats");
172        let loader = DirLoader::new(vec![dir.0.clone()]);
173
174        // `<user>/<Name>/<version>` also resolves against a bare `<Name>.pine`.
175        assert_eq!(loader.load_library("alice/Stats/3").unwrap(), "// stats");
176    }
177}