Skip to main content

bynk_driver/
source_overlay.rs

1//! #2471: an in-memory overlay of unsaved editor buffers for a build.
2//!
3//! `bynkc compile --sources-stdin` reads a JSON object `{ "<absolute path>":
4//! "<text>" }` from stdin and layers it over the sources the build reads from
5//! disk, so an editor can compile what is on screen without saving it. The
6//! overlay is the `CompileOptions::sources` map `compile_project` already
7//! accepts; this module only reconciles its keys with the shape discovery
8//! produced.
9//!
10//! **Which paths are accepted.** A path naming a source the build already read
11//! replaces that source's text (the overlay wins over disk). A path that names
12//! a `.bynk` file inside the project root and no existing source is added: an
13//! unsaved new file. Anything else (a path outside the project, a non-`.bynk`
14//! file) is *rejected* rather than ignored: the caller would otherwise believe
15//! its buffer was compiled. A rejected overlay builds nothing.
16
17use std::collections::HashMap;
18use std::fmt;
19use std::path::{Path, PathBuf};
20
21use bynk_emit::project::CompileOptions;
22
23/// Why an overlay could not be applied.
24#[derive(Debug, PartialEq, Eq)]
25pub enum OverlayError {
26    /// The stdin document is not a JSON object of strings.
27    Malformed(String),
28    /// The build has no in-memory sources to layer onto (it read none).
29    NoSources,
30    /// `path` lies outside the project root.
31    OutsideProject(PathBuf),
32    /// `path` is not a `.bynk` source.
33    NotBynk(PathBuf),
34}
35
36impl fmt::Display for OverlayError {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            OverlayError::Malformed(e) => write!(
40                f,
41                "the sources document must be a JSON object of path to text: {e}"
42            ),
43            OverlayError::NoSources => write!(f, "the project has no sources to overlay"),
44            OverlayError::OutsideProject(p) => {
45                write!(f, "`{}` is outside the project", p.display())
46            }
47            OverlayError::NotBynk(p) => write!(f, "`{}` is not a .bynk source", p.display()),
48        }
49    }
50}
51
52impl std::error::Error for OverlayError {}
53
54/// Parse the `--sources-stdin` document.
55pub fn parse_overlay(json: &str) -> Result<HashMap<PathBuf, String>, OverlayError> {
56    let map: HashMap<String, String> =
57        serde_json::from_str(json).map_err(|e| OverlayError::Malformed(e.to_string()))?;
58    Ok(map
59        .into_iter()
60        .map(|(k, v)| (PathBuf::from(k), v))
61        .collect())
62}
63
64/// Layer `overlay` over `options`' sources for the project rooted at `root`
65/// (the directory the build was pointed at). See the module doc for which
66/// paths are accepted.
67pub fn apply_overlay(
68    options: CompileOptions,
69    root: &Path,
70    overlay: HashMap<PathBuf, String>,
71) -> Result<CompileOptions, OverlayError> {
72    let mut sources = options.sources.clone().ok_or(OverlayError::NoSources)?;
73    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
74    // Existing keys by canonical path, so an overlay entry finds the key
75    // discovery used however either side spells the path.
76    let by_canonical: HashMap<PathBuf, PathBuf> = sources
77        .keys()
78        .map(|k| (k.canonicalize().unwrap_or_else(|_| k.clone()), k.clone()))
79        .collect();
80    let mut replacements = Vec::new();
81    for (path, text) in overlay {
82        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
83        if let Some(key) = by_canonical.get(&canonical) {
84            replacements.push((key.clone(), text));
85            continue;
86        }
87        let Ok(rel) = canonical.strip_prefix(&canonical_root) else {
88            return Err(OverlayError::OutsideProject(path));
89        };
90        if canonical.extension().and_then(|e| e.to_str()) != Some("bynk") {
91            return Err(OverlayError::NotBynk(path));
92        }
93        replacements.push((root.join(rel), text));
94    }
95    for (key, text) in replacements {
96        sources.insert(key, text);
97    }
98    Ok(options.sources(sources))
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    struct Scratch(PathBuf);
106    impl Drop for Scratch {
107        fn drop(&mut self) {
108            let _ = std::fs::remove_dir_all(&self.0);
109        }
110    }
111
112    fn project(tag: &str) -> Scratch {
113        let dir = std::env::temp_dir().join(format!(
114            "bynk_driver_overlay_{tag}_{}_{:?}",
115            std::process::id(),
116            std::thread::current().id()
117        ));
118        let _ = std::fs::remove_dir_all(&dir);
119        std::fs::create_dir_all(dir.join("src")).unwrap();
120        std::fs::write(dir.join("bynk.toml"), "[project]\nname = \"p\"\n").unwrap();
121        std::fs::write(dir.join("src/a.bynk"), "context p.a\n").unwrap();
122        Scratch(dir.canonicalize().unwrap())
123    }
124
125    fn text_of(options: &CompileOptions, file: &Path) -> Option<String> {
126        let want = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
127        options
128            .sources
129            .as_ref()?
130            .iter()
131            .find(|(k, _)| k.canonicalize().unwrap_or_else(|_| (*k).clone()) == want)
132            .map(|(_, v)| v.clone())
133    }
134
135    #[test]
136    fn the_overlay_wins_over_disk() {
137        let dir = project("wins");
138        let options = crate::try_project_options(&dir.0).unwrap();
139        let a = dir.0.join("src/a.bynk");
140        let out = apply_overlay(
141            options,
142            &dir.0,
143            HashMap::from([(a.clone(), "UNSAVED".into())]),
144        )
145        .unwrap();
146        assert_eq!(text_of(&out, &a).as_deref(), Some("UNSAVED"));
147        // Replaced in place: no second entry for the same file.
148        let same = out
149            .sources
150            .as_ref()
151            .unwrap()
152            .keys()
153            .filter(|k| k.ends_with("src/a.bynk"))
154            .count();
155        assert_eq!(same, 1);
156    }
157
158    #[test]
159    fn a_new_bynk_file_inside_the_project_is_added() {
160        let dir = project("new");
161        let options = crate::try_project_options(&dir.0).unwrap();
162        let b = dir.0.join("src/b.bynk");
163        let out = apply_overlay(
164            options,
165            &dir.0,
166            HashMap::from([(b.clone(), "context p.b\n".into())]),
167        )
168        .unwrap();
169        assert_eq!(text_of(&out, &b).as_deref(), Some("context p.b\n"));
170    }
171
172    #[test]
173    fn a_path_outside_the_project_is_rejected() {
174        let dir = project("outside");
175        let options = crate::try_project_options(&dir.0).unwrap();
176        let elsewhere = std::env::temp_dir().join("elsewhere.bynk");
177        let err = apply_overlay(
178            options,
179            &dir.0,
180            HashMap::from([(elsewhere.clone(), String::new())]),
181        )
182        .err()
183        .unwrap();
184        assert_eq!(err, OverlayError::OutsideProject(elsewhere));
185    }
186
187    #[test]
188    fn a_non_bynk_path_is_rejected() {
189        let dir = project("notbynk");
190        let options = crate::try_project_options(&dir.0).unwrap();
191        let toml = dir.0.join("src/notes.txt");
192        let err = apply_overlay(
193            options,
194            &dir.0,
195            HashMap::from([(toml.clone(), String::new())]),
196        )
197        .err()
198        .unwrap();
199        assert_eq!(err, OverlayError::NotBynk(toml));
200    }
201
202    #[test]
203    fn a_malformed_document_is_reported() {
204        assert!(matches!(
205            parse_overlay("[1]"),
206            Err(OverlayError::Malformed(_))
207        ));
208        assert_eq!(
209            parse_overlay(r#"{"/p/a.bynk":"x"}"#).unwrap(),
210            HashMap::from([(PathBuf::from("/p/a.bynk"), "x".to_string())])
211        );
212    }
213}