Skip to main content

zelkova_compiler/source/
overlay.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4/// The text of the buffers an editor holds open, to be read in place of what is on disk.
5///
6/// A module's text normally comes from its file. A language server checks what the
7/// editor holds, which differs from the disk until the user saves, so the checking entry
8/// points ([`check_package`](crate::check_package) and
9/// [`check_package_with_tests`](crate::check_package_with_tests)) take an
10/// `Overlay` and every `.zel` file is read through it. The functions that write a build
11/// take none: no build is ever written from the text of a buffer.
12///
13/// An `Overlay` is a concrete type and not a trait, because it is the only source of
14/// text other than the disk anything wants. The functions that take it take an
15/// `&Overlay` where a `&dyn` would go, so making it one later changes a type and no call
16/// graph.
17///
18/// The rules, which outlive the ticket (`TOOL-2`) that set them:
19///
20/// - **A path is normalised before every comparison, on both sides.** [`insert`],
21///   [`remove`] and every lookup canonicalize the deepest ancestor of the path that
22///   exists on disk and append the rest unchanged, so a path that is not on disk yet
23///   still normalises. The two sides are spelled differently otherwise: package roots are
24///   canonicalized by `resolve`, so a walk yields paths under the canonical root, while a
25///   caller's path is whatever its editor reported (`/tmp/..` against `/private/tmp/..`
26///   on macOS). The walk follows symbolic links, so a file reached through one is matched
27///   by where it points. Normalisation is for the lookup only: a module is still named
28///   from the path the walk yielded.
29/// - **The walk adds what only the overlay holds.** After the walk of a source root, every
30///   key that ends in `.zel`, sits under the normalised form of that root and was not
31///   matched by a walked file is loaded too, in path order, and named by its path under
32///   the normalised root. A `tests/` that is not on disk does not stop that, but a `src/`
33///   that is not on disk is the error it always is, whatever the overlay holds.
34/// - **It only replaces and adds.** There is no way to say a file on disk is gone: an
35///   editor that deletes a module deletes the file, and the next walk does not find it.
36/// - **Only `.zel` sources go through it.** A key that does not end in `.zel` is ignored,
37///   so a caller may hand over every open buffer unfiltered. The manifest is read from
38///   disk, because one being typed is malformed most of the time and its error would
39///   clear every `.zel` diagnostic of the package; a facade's companion `.mjs` is read
40///   from disk too, by a build that writes, which takes no overlay.
41/// - **The package has to exist on disk**, with its `zelkova.toml` and its `src/`, for the
42///   overlay to apply to it. A package that exists only in memory is out of scope.
43///
44/// [`insert`]: Overlay::insert
45/// [`remove`]: Overlay::remove
46#[derive(Debug, Default, Clone)]
47pub struct Overlay {
48    buffers: HashMap<PathBuf, String>,
49}
50
51impl Overlay {
52    /// An overlay holding no buffer: every file is read from disk.
53    pub fn new() -> Overlay {
54        Overlay::default()
55    }
56
57    /// Hold `text` as the contents of the file at `path`, replacing any earlier text for
58    /// the same file.
59    pub fn insert(&mut self, path: impl AsRef<Path>, text: String) {
60        self.buffers.insert(normalise(path.as_ref()), text);
61    }
62
63    /// Stop holding the file at `path`, so it is read from disk again. Returns its text
64    /// if it was held.
65    pub fn remove(&mut self, path: impl AsRef<Path>) -> Option<String> {
66        self.buffers.remove(&normalise(path.as_ref()))
67    }
68
69    /// The text held for the file at `path`, if any.
70    pub(super) fn get(&self, path: &Path) -> Option<&str> {
71        self.buffers.get(&normalise(path)).map(String::as_str)
72    }
73
74    /// Every key that ends in `.zel` and sits under `root`, in path order. `root` must
75    /// already be normalised.
76    pub(super) fn zel_files_under(&self, root: &Path) -> Vec<&Path> {
77        let mut paths: Vec<&Path> = self
78            .buffers
79            .keys()
80            .map(PathBuf::as_path)
81            .filter(|path| path.starts_with(root))
82            .filter(|path| path.extension().is_some_and(|ext| ext == "zel"))
83            .collect();
84        paths.sort();
85        paths
86    }
87}
88
89/// The one spelling of a path that both sides of a comparison are put in: the deepest
90/// ancestor that exists on disk, canonicalized, with the components below it appended
91/// unchanged. A path none of whose ancestors can be canonicalized (a relative path with no
92/// existing prefix, or one whose last component is `..` below a missing directory) comes
93/// back as it was.
94pub(super) fn normalise(path: &Path) -> PathBuf {
95    let mut rest: Vec<std::ffi::OsString> = Vec::new();
96    let mut current = path.to_path_buf();
97    loop {
98        match current.canonicalize() {
99            Ok(mut base) => {
100                base.extend(rest.iter().rev());
101                return base;
102            }
103            Err(_) => match (current.file_name(), current.parent()) {
104                (Some(name), Some(parent)) => {
105                    rest.push(name.to_owned());
106                    let parent = parent.to_path_buf();
107                    current = parent;
108                }
109                _ => return path.to_path_buf(),
110            },
111        }
112    }
113}