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}