Skip to main content

geop_core_program/
workspace.rs

1//! [`Workspace`]: where the programs a program places, and the files its
2//! steps read, come from — the one thing a run reads that is not given to
3//! it as inputs or state, and not built by its steps — and [`Files`], a
4//! workspace of files read from a [`FileStore`].
5//!
6//! A program never knows where it is. The runner running it does
7//! ([`Location`]): the file it is in, and the files placing it. A make
8//! reads a file through its reader ([`crate::reader::Reader::read_file`],
9//! [`crate::reader::Reader::program`]), which records the file's revision
10//! ([`Workspace::revision`]): its node is made again exactly when the file
11//! changed.
12//!
13//! Placing a part runs the program of another file, which may place parts
14//! of its own. So the files of a workspace form a graph, and it has to be
15//! acyclic: a file that places itself, directly or through others, would
16//! never finish building. The runner refuses such a placement with an
17//! error naming the cycle.
18//!
19//! Files name each other by `/`-separated paths relative to the file doing
20//! the naming — `bolt.geop`, `../parts/bolt.geop` — so a folder of files
21//! can be moved as a whole. [`resolve`] turns such a reference into the
22//! path a workspace knows the file by, [`relative`] goes back.
23//!
24//! A file of a library every workspace shares — the standard parts an
25//! application offers — is named by a scheme instead: `std:hex_nut.geop`
26//! (see [`is_shared`]). Such a name means the same file from anywhere, so
27//! it is never relative.
28
29use std::{
30    cell::{Cell, RefCell},
31    collections::{BTreeMap, BTreeSet},
32    rc::Rc,
33};
34
35use geop_core_math::geop_error::{GeopError, GeopResult};
36
37use crate::{cache::Cache, operation_registry::OperationRegistry, program::Program};
38
39/// Programs and other files, by path.
40pub trait Workspace {
41    /// The program in the file at `path`, with the state it was saved
42    /// with: the same each time it is asked for while the file does not
43    /// change.
44    fn program(&self, path: &str) -> GeopResult<Rc<Program>>;
45
46    /// The text of the file at `path`: a STEP file a step imports. The same
47    /// each time it is asked for while the file does not change.
48    fn read(&self, path: &str) -> GeopResult<Rc<str>>;
49
50    /// Every file there is, by path.
51    fn list(&self) -> Vec<String>;
52
53    /// The path of the file the file at `from` names `name`.
54    fn resolve(&self, from: &str, name: &str) -> String;
55
56    /// How the file at `from` names the file at `path`.
57    fn relative(&self, from: &str, path: &str) -> String;
58
59    /// The revision of the file at `path`: changes whenever the file
60    /// does, so a node that read it is made again.
61    fn revision(&self, path: &str) -> u64;
62
63    /// What it keeps of what its files were made into, shared by every
64    /// build reading them: none, for a workspace that keeps nothing, whose
65    /// files are made into what they read as each time they are read.
66    fn cache(&self) -> Option<&Cache> {
67        None
68    }
69
70    /// A digest of the text of the file at `path` as it is now: what a node
71    /// that read it was made from, in any process (see [`crate::kept`]).
72    /// None for a file it does not have.
73    fn digest(&self, path: &str) -> Option<u128> {
74        self.read(path)
75            .ok()
76            .map(|text| crate::kept::digest_text(&text))
77    }
78}
79
80/// Where the program a runner runs is: the file it is in, and the files
81/// placing it, outermost first — which it must not place again, or the
82/// files would place themselves. The runner's to know, never the
83/// program's.
84#[derive(Clone, Debug, Default, PartialEq)]
85pub struct Location {
86    pub file: String,
87    pub within: Vec<String>,
88}
89
90/// Whether the file `path` is a program, which a part can be placed from:
91/// a `.geop` file.
92pub fn is_program(path: &str) -> bool {
93    path.ends_with(".geop")
94}
95
96/// What the content of a file of bytes that are not text starts with: a
97/// `data:` URL of them, in base64 (see [`content`]).
98pub const BYTES_PREFIX: &str = "data:application/octet-stream;base64,";
99
100/// The content of a file of `bytes`, as a workspace holds it: a file holds
101/// text, so the text they spell where they are UTF-8 throughout, and
102/// otherwise — a binary STL, a STEP file with a Latin-1 name in it — a
103/// `data:` URL of them in base64. A front end reading a file a user chose
104/// holds it the same way, so files travel as text everywhere and only
105/// what reads bytes ([`bytes`]) or text ([`text`]) decodes them.
106pub fn content(bytes: Vec<u8>) -> String {
107    match String::from_utf8(bytes) {
108        Ok(text) => text,
109        Err(e) => format!("{BYTES_PREFIX}{}", base64(e.as_bytes())),
110    }
111}
112
113/// The bytes of a file whose content is `content` (see [`content`]).
114pub fn bytes(content: &str) -> GeopResult<std::borrow::Cow<'_, [u8]>> {
115    match content.strip_prefix(BYTES_PREFIX) {
116        Some(encoded) => Ok(std::borrow::Cow::Owned(from_base64(encoded)?)),
117        None => Ok(std::borrow::Cow::Borrowed(content.as_bytes())),
118    }
119}
120
121/// The text of a file whose content is `content` (see [`content`]): its
122/// bytes read as UTF-8, what is not replaced.
123pub fn text(content: &str) -> std::borrow::Cow<'_, str> {
124    match bytes(content) {
125        Ok(std::borrow::Cow::Owned(bytes)) => {
126            std::borrow::Cow::Owned(String::from_utf8_lossy(&bytes).into_owned())
127        }
128        _ => std::borrow::Cow::Borrowed(content),
129    }
130}
131
132/// The base64 alphabet of RFC 4648.
133const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
134
135/// `bytes` as base64 text (RFC 4648, padded).
136pub fn base64(bytes: &[u8]) -> String {
137    let mut text = String::with_capacity(bytes.len().div_ceil(3) * 4);
138    for chunk in bytes.chunks(3) {
139        let b = [0, 1, 2].map(|k| chunk.get(k).copied().unwrap_or(0) as u32);
140        let n = (b[0] << 16) | (b[1] << 8) | b[2];
141        for k in 0..4 {
142            text.push(match BASE64.get((n >> (18 - 6 * k) & 63) as usize) {
143                Some(&digit) if k <= chunk.len() => digit as char,
144                _ => '=',
145            });
146        }
147    }
148    text
149}
150
151/// The bytes of the base64 text `text` (RFC 4648, padded or not).
152pub fn from_base64(text: &str) -> GeopResult<Vec<u8>> {
153    let digits = text.trim_end_matches('=').as_bytes();
154    let mut bytes = Vec::with_capacity(digits.len() * 3 / 4);
155    let (mut n, mut bits) = (0u32, 0);
156    for &c in digits {
157        let Some(value) = BASE64.iter().position(|&d| d == c) else {
158            return Err(GeopError::new(format!(
159                "{:?} is no base64 digit",
160                c as char
161            )));
162        };
163        n = (n << 6) | value as u32;
164        bits += 6;
165        if bits >= 8 {
166            bits -= 8;
167            bytes.push((n >> bits) as u8);
168            n &= (1 << bits) - 1;
169        }
170    }
171    Ok(bytes)
172}
173
174/// Where [`Files`] reads files from.
175pub trait FileStore {
176    /// The text of the file `path`.
177    fn read(&self, path: &str) -> GeopResult<String>;
178    /// Every file there is, by path: programs and the files they read.
179    fn list(&self) -> Vec<String>;
180}
181
182/// [`FileStore`] [`Files`] can change ([`Files::write`]).
183pub trait FileStoreMut: FileStore {
184    /// Sets the file `path` to `text`, or — `None` — removes it.
185    fn write(&mut self, path: &str, text: Option<String>);
186}
187
188/// Files held in memory, by path: what an editor is sent by its front end.
189impl FileStore for BTreeMap<String, String> {
190    fn read(&self, path: &str) -> GeopResult<String> {
191        self.get(path)
192            .cloned()
193            .ok_or_else(|| GeopError::new(format!("there is no file {path:?}")))
194    }
195
196    fn list(&self) -> Vec<String> {
197        self.keys().cloned().collect()
198    }
199}
200
201impl FileStoreMut for BTreeMap<String, String> {
202    fn write(&mut self, path: &str, text: Option<String>) {
203        match text {
204            Some(text) => self.insert(path.to_string(), text),
205            None => self.remove(path),
206        };
207    }
208}
209
210/// Files of a store chosen at run time: an editor's, whose files are
211/// whatever its front end keeps, with whatever library beside them.
212impl FileStore for Box<dyn FileStoreMut> {
213    fn read(&self, path: &str) -> GeopResult<String> {
214        (**self).read(path)
215    }
216
217    fn list(&self) -> Vec<String> {
218        (**self).list()
219    }
220}
221
222impl FileStoreMut for Box<dyn FileStoreMut> {
223    fn write(&mut self, path: &str, text: Option<String>) {
224        (**self).write(path, text)
225    }
226}
227
228/// A set of files, whose programs are written in the operations of
229/// `registry`: the [`Workspace`] its programs are run with, reading the
230/// programs they place and the files they import from it. A program read
231/// is checked against the operations (see [`Program::validate`]).
232///
233/// Each file is read once, and read again once it changes
234/// ([`Files::write`]), when its revision changes too.
235pub struct Files<F: 'static = BTreeMap<String, String>> {
236    files: RefCell<F>,
237    registry: Rc<OperationRegistry>,
238    /// Each file's program, read once: by its path.
239    programs: RefCell<BTreeMap<String, Rc<Program>>>,
240    /// Each other file's text, read once: by its path.
241    texts: RefCell<BTreeMap<String, Rc<str>>>,
242    /// The digest of each file's text, taken when it is read: by its path.
243    digests: RefCell<BTreeMap<String, u128>>,
244    /// How many times a file changed ([`Files::write`]).
245    revision: Cell<u64>,
246    /// The revision of each file that changed: the count when it last did.
247    revisions: RefCell<BTreeMap<String, u64>>,
248    /// What its files were made into.
249    cache: Cache,
250}
251
252impl<F: FileStore + 'static> Files<F> {
253    /// The files `files`, what they are made into kept in memory.
254    pub fn new(files: F, registry: Rc<OperationRegistry>) -> Rc<Self> {
255        Self::with_cache(files, registry, Cache::new())
256    }
257
258    /// The files `files`, what they are made into kept in `cache`.
259    pub fn with_cache(files: F, registry: Rc<OperationRegistry>, cache: Cache) -> Rc<Self> {
260        Rc::new(Self {
261            files: RefCell::new(files),
262            registry,
263            programs: RefCell::new(BTreeMap::new()),
264            texts: RefCell::new(BTreeMap::new()),
265            digests: RefCell::new(BTreeMap::new()),
266            revision: Cell::new(0),
267            revisions: RefCell::new(BTreeMap::new()),
268            cache,
269        })
270    }
271
272    /// The files, to read.
273    pub fn files(&self) -> std::cell::Ref<'_, F> {
274        self.files.borrow()
275    }
276
277    /// The operations its programs are written in.
278    pub fn registry(&self) -> &Rc<OperationRegistry> {
279        &self.registry
280    }
281
282    /// Forgets what was read from any of the files `changed` — paths as
283    /// [`resolve`] gives them.
284    fn forget(&self, changed: &BTreeSet<String>) {
285        (self.programs.borrow_mut()).retain(|path, _| !changed.contains(path));
286        (self.texts.borrow_mut()).retain(|path, _| !changed.contains(path));
287        (self.digests.borrow_mut()).retain(|path, _| !changed.contains(path));
288    }
289
290    /// Notes the digest of `text`, the file `path` as read.
291    fn read_as(&self, path: &str, text: &str) {
292        let digest = crate::kept::digest_text(text);
293        (self.digests.borrow_mut()).insert(path.to_string(), digest);
294    }
295}
296
297impl<F: FileStoreMut + 'static> Files<F> {
298    /// Sets the file `path` to `text`, or — `None` — removes it, and
299    /// forgets what was read from it (see [`Files`]). Says whether it
300    /// changed anything: written as it was, nothing is forgotten.
301    pub fn write(&self, path: &str, text: Option<String>) -> bool {
302        if self.files.borrow().read(path).ok() == text {
303            return false;
304        }
305        self.files.borrow_mut().write(path, text);
306        let path = resolve("", path);
307        self.forget(&BTreeSet::from([path.clone()]));
308        self.revision.set(self.revision.get() + 1);
309        (self.revisions.borrow_mut()).insert(path, self.revision.get());
310        true
311    }
312}
313
314impl<F: FileStore + 'static> Workspace for Files<F> {
315    fn program(&self, path: &str) -> GeopResult<Rc<Program>> {
316        if let Some(program) = self.programs.borrow().get(path) {
317            return Ok(program.clone());
318        }
319        let text = self.files.borrow().read(path)?;
320        self.read_as(path, &text);
321        let program = (Program::from_json(&text))
322            .and_then(|program| program.validate(&self.registry).map(|()| program))
323            .map_err(|e| e.with_context(format!("reading {path}")))?;
324        let program = Rc::new(program);
325        (self.programs.borrow_mut()).insert(path.to_string(), program.clone());
326        Ok(program)
327    }
328
329    fn read(&self, path: &str) -> GeopResult<Rc<str>> {
330        if let Some(text) = self.texts.borrow().get(path) {
331            return Ok(text.clone());
332        }
333        let text: Rc<str> = self.files.borrow().read(path)?.into();
334        self.read_as(path, &text);
335        (self.texts.borrow_mut()).insert(path.to_string(), text.clone());
336        Ok(text)
337    }
338
339    fn list(&self) -> Vec<String> {
340        (self.files.borrow().list().into_iter())
341            .map(|f| resolve("", &f))
342            .collect()
343    }
344
345    fn resolve(&self, from: &str, name: &str) -> String {
346        resolve(from, name)
347    }
348
349    fn relative(&self, from: &str, path: &str) -> String {
350        relative(from, path)
351    }
352
353    fn revision(&self, path: &str) -> u64 {
354        self.revisions
355            .borrow()
356            .get(path)
357            .copied()
358            .unwrap_or_default()
359    }
360
361    fn cache(&self) -> Option<&Cache> {
362        Some(&self.cache)
363    }
364
365    fn digest(&self, path: &str) -> Option<u128> {
366        if let Some(digest) = self.digests.borrow().get(path) {
367            return Some(*digest);
368        }
369        self.read(path).ok()?;
370        self.digests.borrow().get(path).copied()
371    }
372}
373
374/// Whether `reference` names a file of a library every workspace shares,
375/// by a scheme of two letters or more and `:` — `std:hex_nut.geop`. It
376/// names the same file from anywhere: it is never relative. (A single
377/// letter before the `:` is a drive, `C:/parts/bolt.geop`.)
378pub fn is_shared(reference: &str) -> bool {
379    reference.split_once(':').is_some_and(|(scheme, _)| {
380        scheme.len() >= 2 && scheme.chars().all(|c| c.is_ascii_alphabetic())
381    })
382}
383
384/// The path of the file `reference` names, from the file `from`: relative
385/// to the folder `from` is in, unless it starts with `/` or is a shared
386/// library's (see [`is_shared`]). `.` and `..` are taken out where they
387/// can be, and `\` read as `/`.
388pub fn resolve(from: &str, reference: &str) -> String {
389    if is_shared(reference) {
390        return reference.to_string();
391    }
392    let reference = reference.replace('\\', "/");
393    let from = from.replace('\\', "/");
394    let joined = match (reference.starts_with('/'), from.rsplit_once('/')) {
395        (true, _) | (false, None) => reference,
396        (false, Some((folder, _))) => format!("{folder}/{reference}"),
397    };
398    let absolute = joined.starts_with('/');
399    let mut parts: Vec<&str> = Vec::new();
400    for part in joined.split('/') {
401        match part {
402            "" | "." => {}
403            ".." if parts.last().is_some_and(|p| *p != "..") => {
404                parts.pop();
405            }
406            ".." if absolute => {}
407            part => parts.push(part),
408        }
409    }
410    let path = parts.join("/");
411    if absolute { format!("/{path}") } else { path }
412}
413
414/// How the file `from` names the file `to`, both paths as [`resolve`]
415/// gives them: relative to the folder `from` is in — a shared library's
416/// file by its own name (see [`is_shared`]).
417pub fn relative(from: &str, to: &str) -> String {
418    if is_shared(to) {
419        return to.to_string();
420    }
421    let mut folders: Vec<&str> = from.split('/').collect();
422    folders.pop();
423    let target: Vec<&str> = to.split('/').collect();
424    let common = folders
425        .iter()
426        .zip(&target)
427        .take_while(|(a, b)| a == b)
428        .count()
429        .min(target.len().saturating_sub(1));
430    let up = std::iter::repeat_n("..", folders.len() - common);
431    up.chain(target.iter().skip(common).copied())
432        .collect::<Vec<_>>()
433        .join("/")
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    /// Text is held as it is; bytes that are not text as a `data:` URL,
441    /// which gives them back whole, and as text with what is not UTF-8
442    /// replaced. Base64 as RFC 4648 spells it.
443    #[test]
444    fn files_hold_bytes_as_text() {
445        assert_eq!(content(b"solid cube".to_vec()), "solid cube");
446        assert_eq!(&*bytes("solid cube").unwrap(), b"solid cube");
447        assert_eq!(base64(b"foobar"), "Zm9vYmFy");
448        assert_eq!(base64(b"fo"), "Zm8=");
449        // As the front end holds them (`web/test/files.test.ts`).
450        assert_eq!(
451            content(vec![0xff, 0xf8, 0xf1]),
452            format!("{BYTES_PREFIX}//jx")
453        );
454        for n in 0..7 {
455            let binary: Vec<u8> = (0..n).map(|k| 0xff - 7 * k as u8).collect();
456            let held = content(binary.clone());
457            if n > 0 {
458                assert!(held.starts_with(BYTES_PREFIX), "{held}");
459            }
460            assert_eq!(&*bytes(&held).unwrap(), &binary[..]);
461        }
462        let latin1 = content(b"caf\xe9".to_vec());
463        assert_eq!(text(&latin1), "caf\u{fffd}");
464        assert!(bytes(&format!("{BYTES_PREFIX}!!")).is_err());
465    }
466
467    #[test]
468    fn references_resolve_relative_to_their_file() {
469        assert_eq!(resolve("asm.geop", "bolt.geop"), "bolt.geop");
470        assert_eq!(resolve("a/asm.geop", "bolt.geop"), "a/bolt.geop");
471        assert_eq!(resolve("a/asm.geop", "../b/bolt.geop"), "b/bolt.geop");
472        assert_eq!(resolve("a/asm.geop", "./x/../bolt.geop"), "a/bolt.geop");
473        assert_eq!(
474            resolve("/home/a/asm.geop", "bolt.geop"),
475            "/home/a/bolt.geop"
476        );
477        assert_eq!(resolve("a/asm.geop", "/b/bolt.geop"), "/b/bolt.geop");
478        assert_eq!(resolve("asm.geop", "../bolt.geop"), "../bolt.geop");
479        assert_eq!(resolve("a/b/asm.geop", "std:nut.geop"), "std:nut.geop");
480        assert_eq!(resolve("asm.geop", "C:/b/bolt.geop"), "C:/b/bolt.geop");
481    }
482
483    #[test]
484    fn shared_files_are_named_alike_from_anywhere() {
485        assert!(is_shared("std:nut.geop"));
486        assert!(!is_shared("C:/parts/nut.geop"));
487        assert!(!is_shared("parts/nut.geop"));
488        assert_eq!(relative("a/b/asm.geop", "std:nut.geop"), "std:nut.geop");
489    }
490
491    #[test]
492    fn relative_names_resolve_back() {
493        for (from, to) in [
494            ("asm.geop", "bolt.geop"),
495            ("a/asm.geop", "a/bolt.geop"),
496            ("a/asm.geop", "b/c/bolt.geop"),
497            ("a/b/asm.geop", "bolt.geop"),
498        ] {
499            let name = relative(from, to);
500            assert_eq!(resolve(from, &name), to, "{from} -> {to} named {name}");
501        }
502        assert_eq!(relative("a/asm.geop", "b/bolt.geop"), "../b/bolt.geop");
503    }
504}