Skip to main content

orrery_parser/
source_provider.rs

1//! Source provider abstraction for file I/O.
2//!
3//! This module decouples the import resolver from the filesystem, enabling
4//! different environments (CLI, tests, LSP, WASM) to supply their own
5//! file-reading strategy.
6//!
7//! # Overview
8//!
9//! - [`SourceProvider`] — trait with three methods: [`resolve_path`](SourceProvider::resolve_path),
10//!   [`read_source`](SourceProvider::read_source), and [`derive_namespace`](SourceProvider::derive_namespace).
11//! - [`SourceError`] — lightweight, `Clone`-able error returned by providers
12//!   (defined in the [`error`](crate::error) module).
13
14use std::path::{Path, PathBuf};
15
16use orrery_core::identifier::Id;
17
18use crate::error::SourceError;
19
20/// Abstraction over file I/O for the import resolver.
21///
22/// Implementations provide environment-specific file resolution and reading.
23/// The trait is object-safe so the resolver can use `&dyn SourceProvider`.
24pub trait SourceProvider {
25    /// Resolve an import path relative to the importing file.
26    ///
27    /// The implementation must:
28    /// 1. Determine the directory of `from` (the importing file).
29    /// 2. Join it with `import_path`.
30    /// 3. Append the `.orr` extension.
31    /// 4. Return a normalized/canonical path for deduplication.
32    ///
33    /// # Arguments
34    ///
35    /// * `from` — Path of the file containing the `import` statement.
36    /// * `import_path` — The raw path string from source (e.g., `"shared/styles"`).
37    ///
38    /// # Errors
39    ///
40    /// Returns [`SourceError`] if the path cannot be resolved.
41    fn resolve_path(&self, from: &Path, import_path: &str) -> Result<PathBuf, SourceError>;
42
43    /// Read the source text of a file at the given path.
44    ///
45    /// The `path` argument should be a value previously returned by
46    /// [`resolve_path`](Self::resolve_path).
47    ///
48    /// # Errors
49    ///
50    /// Returns [`SourceError`] if the file cannot be read.
51    fn read_source(&self, path: &Path) -> Result<String, SourceError>;
52
53    /// Derives a namespace [`Id`] from an import path.
54    ///
55    /// The default implementation extracts the final component's file stem
56    /// (e.g. `shared/styles` → `styles`, `../common/base.orr` → `base`).
57    ///
58    /// # Arguments
59    ///
60    /// * `import_path` — The import path as a [`Path`] reference.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`SourceError`] if a valid namespace name cannot be derived
65    /// from the import path (e.g. the path has no file stem or contains
66    /// non-UTF-8 characters).
67    fn derive_namespace(&self, import_path: &Path) -> Result<Id, SourceError> {
68        let name = import_path
69            .file_stem()
70            .and_then(|s| s.to_str())
71            .ok_or_else(|| {
72                SourceError::new(
73                    import_path,
74                    "cannot derive namespace: path has no valid file stem",
75                )
76            })?;
77        Ok(Id::new(name))
78    }
79}
80
81/// Blanket implementation that delegates all [`SourceProvider`] methods
82/// through a shared reference.
83///
84/// This allows `&P` to satisfy a `P: SourceProvider` bound.
85impl<P: SourceProvider> SourceProvider for &P {
86    fn resolve_path(&self, from: &Path, import_path: &str) -> Result<PathBuf, SourceError> {
87        (**self).resolve_path(from, import_path)
88    }
89
90    fn read_source(&self, path: &Path) -> Result<String, SourceError> {
91        (**self).read_source(path)
92    }
93
94    fn derive_namespace(&self, import_path: &Path) -> Result<Id, SourceError> {
95        (**self).derive_namespace(import_path)
96    }
97}
98
99/// An in-memory [`SourceProvider`] backed by a `HashMap`.
100///
101/// Useful for testing and environments without filesystem access.
102/// Keys are stored and looked up **exactly** as provided — there is no
103/// path normalization. The caller controls both the registered keys and
104/// the import paths, so they are responsible for making them match.
105///
106/// `resolve_path` joins the parent directory of `from` with `import_path`,
107/// appends `.orr`, and does a direct HashMap lookup on the result.
108#[derive(Debug, Clone, Default)]
109pub struct InMemorySourceProvider {
110    files: std::collections::HashMap<PathBuf, String>,
111}
112
113impl InMemorySourceProvider {
114    /// Creates a new, empty in-memory source provider.
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Registers a file in this provider.
120    pub fn add_file(&mut self, path: impl Into<PathBuf>, source: impl Into<String>) {
121        self.files.insert(path.into(), source.into());
122    }
123}
124
125impl SourceProvider for InMemorySourceProvider {
126    fn resolve_path(&self, from: &Path, import_path: &str) -> Result<PathBuf, SourceError> {
127        let dir = from.parent().unwrap_or_else(|| Path::new(""));
128
129        let mut target = dir.join(import_path);
130        target.set_extension("orr");
131
132        if self.files.contains_key(&target) {
133            Ok(target)
134        } else {
135            Err(SourceError::new(
136                &target,
137                format!("file not found: {}", target.display()),
138            ))
139        }
140    }
141
142    fn read_source(&self, path: &Path) -> Result<String, SourceError> {
143        self.files
144            .get(path)
145            .cloned()
146            .ok_or_else(|| SourceError::new(path, format!("file not found: {}", path.display())))
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn resolve_path_same_directory() {
156        let mut provider = InMemorySourceProvider::new();
157        provider.add_file("styles.orr", "library;");
158
159        let resolved = provider
160            .resolve_path(Path::new("main.orr"), "styles")
161            .unwrap();
162        assert_eq!(resolved, PathBuf::from("styles.orr"));
163    }
164
165    #[test]
166    fn resolve_path_subdirectory() {
167        let mut provider = InMemorySourceProvider::new();
168        provider.add_file("shared/styles.orr", "library;");
169
170        let resolved = provider
171            .resolve_path(Path::new("main.orr"), "shared/styles")
172            .unwrap();
173        assert_eq!(resolved, PathBuf::from("shared/styles.orr"));
174    }
175
176    #[test]
177    fn resolve_path_from_nested_file() {
178        let mut provider = InMemorySourceProvider::new();
179        provider.add_file("shared/base.orr", "library;");
180
181        // shared/ext.orr imports "base" → resolves to shared/base.orr
182        let resolved = provider
183            .resolve_path(Path::new("shared/ext.orr"), "base")
184            .unwrap();
185        assert_eq!(resolved, PathBuf::from("shared/base.orr"));
186    }
187
188    #[test]
189    fn resolve_path_file_not_found() {
190        let provider = InMemorySourceProvider::new();
191
192        let err = provider
193            .resolve_path(Path::new("main.orr"), "missing")
194            .unwrap_err();
195        assert_eq!(err.path(), Path::new("missing.orr"));
196        assert!(err.message().contains("file not found"));
197    }
198
199    #[test]
200    fn resolve_path_appends_orr_extension() {
201        let mut provider = InMemorySourceProvider::new();
202        provider.add_file("lib.orr", "library;");
203
204        let resolved = provider.resolve_path(Path::new("main.orr"), "lib").unwrap();
205        assert_eq!(resolved, PathBuf::from("lib.orr"));
206    }
207
208    #[test]
209    fn read_source_existing_file() {
210        let mut provider = InMemorySourceProvider::new();
211        provider.add_file("styles.orr", "library;\ntype Box = Rectangle;");
212
213        let source = provider.read_source(Path::new("styles.orr")).unwrap();
214        assert_eq!(source, "library;\ntype Box = Rectangle;");
215    }
216
217    #[test]
218    fn read_source_missing_file() {
219        let provider = InMemorySourceProvider::new();
220
221        let err = provider.read_source(Path::new("missing.orr")).unwrap_err();
222        assert_eq!(err.path(), Path::new("missing.orr"));
223        assert!(err.message().contains("file not found"));
224    }
225
226    #[test]
227    fn resolve_then_read_round_trip() {
228        let mut provider = InMemorySourceProvider::new();
229        provider.add_file("shared/styles.orr", "library;\ntype S = Rectangle;");
230        provider.add_file("main.orr", "diagram component;");
231
232        let resolved = provider
233            .resolve_path(Path::new("main.orr"), "shared/styles")
234            .unwrap();
235        let source = provider.read_source(&resolved).unwrap();
236        assert!(source.contains("type S = Rectangle"));
237    }
238
239    #[test]
240    fn resolve_chained_imports() {
241        let mut provider = InMemorySourceProvider::new();
242        provider.add_file("base.orr", "library;");
243        provider.add_file("ext.orr", "library;");
244        provider.add_file("main.orr", "diagram component;");
245
246        // main.orr → ext → base
247        let ext_path = provider.resolve_path(Path::new("main.orr"), "ext").unwrap();
248        assert_eq!(ext_path, PathBuf::from("ext.orr"));
249
250        let base_path = provider.resolve_path(&ext_path, "base").unwrap();
251        assert_eq!(base_path, PathBuf::from("base.orr"));
252
253        let base_source = provider.read_source(&base_path).unwrap();
254        assert_eq!(base_source, "library;");
255    }
256
257    #[test]
258    fn resolve_nested_directory_structure() {
259        let mut provider = InMemorySourceProvider::new();
260        provider.add_file("shared/base/types.orr", "library;");
261        provider.add_file("shared/ext.orr", "library;");
262
263        // shared/ext.orr imports base/types → shared/base/types.orr
264        let types = provider
265            .resolve_path(Path::new("shared/ext.orr"), "base/types")
266            .unwrap();
267        assert_eq!(types, PathBuf::from("shared/base/types.orr"));
268    }
269
270    #[test]
271    fn empty_import_path_is_error() {
272        let provider = InMemorySourceProvider::new();
273
274        let err = provider
275            .resolve_path(Path::new("main.orr"), "")
276            .unwrap_err();
277        assert!(err.message().contains("file not found"));
278    }
279
280    #[test]
281    fn derive_namespace() {
282        let provider = InMemorySourceProvider::new();
283        let id = provider.derive_namespace(Path::new("simple")).unwrap();
284        assert!(id == "simple");
285
286        let id = provider
287            .derive_namespace(Path::new("shared/nested"))
288            .unwrap();
289        assert!(id == "nested");
290
291        let id = provider
292            .derive_namespace(Path::new("../relative/path"))
293            .unwrap();
294        assert!(id == "path");
295
296        let id = provider
297            .derive_namespace(Path::new("shared/extension.orr"))
298            .unwrap();
299        assert!(id == "extension");
300    }
301
302    #[test]
303    fn derive_namespace_empty_path_is_error() {
304        let provider = InMemorySourceProvider::new();
305        let err = provider.derive_namespace(Path::new("")).unwrap_err();
306        assert!(
307            err.message().contains("cannot derive namespace"),
308            "expected namespace error, got: {}",
309            err.message()
310        );
311    }
312
313    #[test]
314    fn overwrite_file() {
315        let mut provider = InMemorySourceProvider::new();
316        provider.add_file("a.orr", "version 1");
317        provider.add_file("a.orr", "version 2");
318
319        let source = provider.read_source(Path::new("a.orr")).unwrap();
320        assert_eq!(source, "version 2");
321    }
322}