Skip to main content

lattice_wit/
lib.rs

1//! The canonical plugin API — lattice's `wit/` package — as a crate, so a
2//! plugin can depend on a named ABI generation instead of a copied directory
3//! (WT.1).
4//!
5//! Design: `docs/dev/architecture/wit-ownership.md`.
6//!
7//! **WIT is the canonical plugin API, and lattice owns it.** Every copy of
8//! `wit/` in a plugin tree is a *cache* of this package, never a fork — but
9//! until this crate existed nothing said so and nothing enforced it. A plugin
10//! got its copy once, at scaffold time, and then it silently drifted: three ABI
11//! changes in a day left two installed components unloadable and the editor
12//! said nothing at all.
13//!
14//! The copy exists for a real reason — `wit_bindgen::generate!` resolves its
15//! `path` when the macro expands, so the files must be on disk beside the crate
16//! being compiled. That is a *build-time* need, and a build-time need is met by
17//! the build:
18//!
19//! ```ignore
20//! // a plugin's build.rs
21//! fn main() {
22//!     lattice_wit::write_to("wit").expect("write the lattice API package");
23//! }
24//! ```
25//!
26//! `wit/` becomes generated output, and which ABI a plugin targets becomes a
27//! pinned dependency — which is what it always was.
28//!
29//! ## Zero dependencies, deliberately
30//!
31//! A plugin needs this at build time without building the editor. A crate that
32//! pulled in any editor crate would defeat its own purpose. That constraint is
33//! also why [`ABI_FINGERPRINT`] is an FNV-1a rather than a sha2.
34
35use std::io;
36use std::path::Path;
37
38include!(concat!(env!("OUT_DIR"), "/wit_assets.rs"));
39
40/// Write the embedded package into `dir`, creating it if needed.
41///
42/// Overwrites whatever is there: the directory is a cache of this package, and
43/// a partial refresh — some files current, some stale — resolves into a WIT
44/// package that is internally inconsistent, which fails in ways far harder to
45/// read than a clean overwrite.
46///
47/// Files present in `dir` that are **not** part of the package are left alone.
48/// A plugin may legitimately keep its own world file beside the package (org
49/// does not, but the scaffolds' `world.wit` shape would), and deleting a file
50/// this crate did not write would be taking ownership of a directory it only
51/// contributes to.
52///
53/// **A file already holding the right bytes is not rewritten**, and that is
54/// load-bearing rather than an optimisation. WT.2b calls this from the plugin
55/// build service on every boot, immediately before the source's staleness is
56/// judged — and staleness is judged on mtime. An unconditional write would move
57/// every file's mtime forward on every boot, so every source would look edited,
58/// so every plugin would rebuild from cold on every start. The requirement the
59/// build service exists to protect is precisely the opposite one.
60pub fn write_to(dir: impl AsRef<Path>) -> io::Result<()> {
61    let dir = dir.as_ref();
62    std::fs::create_dir_all(dir)?;
63    for (name, contents) in FILES {
64        let path = dir.join(name);
65        // `read` rather than metadata+len: a truncated or hand-edited file of
66        // coincidentally equal length is exactly the drift this crate exists to
67        // repair, and it must not survive the comparison.
68        if std::fs::read(&path).is_ok_and(|on_disk| on_disk == contents.as_bytes()) {
69            continue;
70        }
71        std::fs::write(path, contents)?;
72    }
73    Ok(())
74}
75
76/// Names of the files this package writes.
77pub fn file_names() -> impl Iterator<Item = &'static str> {
78    FILES.iter().map(|(name, _)| *name)
79}
80
81#[cfg(test)]
82mod tests {
83    #![allow(clippy::unwrap_used)]
84
85    use super::*;
86
87    #[test]
88    fn the_package_carries_the_load_bearing_files() {
89        assert!(!FILES.is_empty(), "the embedded package is not empty");
90        for wanted in ["types.wit", "plugin.wit", "modes.wit", "grammar.wit"] {
91            assert!(
92                file_names().any(|n| n == wanted),
93                "the package carries {wanted}"
94            );
95        }
96    }
97
98    /// The fixture and bundled-plugin worlds stay out. A plugin has no use for
99    /// another plugin's world, and shipping the host's test fixtures into every
100    /// user's config directory would be noise that also has to be kept current.
101    #[test]
102    fn fixture_and_bundled_worlds_are_excluded() {
103        for unwanted in [
104            "auto-pair.wit",
105            "init-fixture.wit",
106            "multiseam-fixture.wit",
107            "trampoline-fixture.wit",
108        ] {
109            assert!(
110                !file_names().any(|n| n == unwanted),
111                "{unwanted} is a world no plugin needs"
112            );
113        }
114    }
115
116    #[test]
117    fn write_to_produces_every_file_and_creates_the_directory() {
118        let tmp = std::env::temp_dir().join(format!(
119            "lattice-wit-write-{}-{}",
120            std::process::id(),
121            line!()
122        ));
123        let _ = std::fs::remove_dir_all(&tmp);
124        // The nested path proves `create_dir_all`, not just `create_dir`.
125        let target = tmp.join("nested").join("wit");
126        write_to(&target).unwrap();
127        for name in file_names() {
128            let written = std::fs::read_to_string(target.join(name)).unwrap();
129            assert!(!written.is_empty(), "{name} written non-empty");
130        }
131        // A package resolve needs the whole set present at once, so count too.
132        assert_eq!(
133            std::fs::read_dir(&target).unwrap().count(),
134            FILES.len(),
135            "every embedded file landed and nothing else did"
136        );
137        let _ = std::fs::remove_dir_all(&tmp);
138    }
139
140    /// A file the package does not own survives a write. The directory is one
141    /// this crate contributes to, not one it owns — a plugin may keep its own
142    /// world file beside the package.
143    #[test]
144    fn write_to_leaves_a_file_it_does_not_own_alone() {
145        let tmp = std::env::temp_dir().join(format!(
146            "lattice-wit-keep-{}-{}",
147            std::process::id(),
148            line!()
149        ));
150        let _ = std::fs::remove_dir_all(&tmp);
151        std::fs::create_dir_all(&tmp).unwrap();
152        std::fs::write(tmp.join("my-world.wit"), "// mine\n").unwrap();
153        write_to(&tmp).unwrap();
154        assert_eq!(
155            std::fs::read_to_string(tmp.join("my-world.wit")).unwrap(),
156            "// mine\n"
157        );
158        let _ = std::fs::remove_dir_all(&tmp);
159    }
160
161    /// **A second write must not touch a file that already holds the right
162    /// bytes.** WT.2b's build service calls `write_to` on every boot just
163    /// before judging the source stale by mtime; if the write were
164    /// unconditional, every boot would move every mtime forward and every
165    /// plugin would rebuild from cold every start — inverting the one
166    /// requirement the build cache exists for.
167    ///
168    /// The sleep is what makes this a test about writing rather than about
169    /// timestamp resolution: without a gap the filesystem could report the same
170    /// mtime for a rewrite that genuinely happened, and the assertion would
171    /// pass on the broken version.
172    #[test]
173    fn an_identical_file_is_not_rewritten() {
174        let tmp = std::env::temp_dir().join(format!(
175            "lattice-wit-idem-{}-{}",
176            std::process::id(),
177            line!()
178        ));
179        let _ = std::fs::remove_dir_all(&tmp);
180        write_to(&tmp).unwrap();
181
182        let probe = tmp.join(file_names().next().unwrap());
183        let mtime = |p: &Path| std::fs::metadata(p).unwrap().modified().unwrap();
184        let before = mtime(&probe);
185
186        std::thread::sleep(std::time::Duration::from_millis(20));
187        write_to(&tmp).unwrap();
188        assert_eq!(
189            mtime(&probe),
190            before,
191            "a file already holding the right bytes is left entirely alone"
192        );
193
194        // ...and a file that DID drift is repaired, contents and all.
195        std::fs::write(&probe, "// someone edited the cache\n").unwrap();
196        write_to(&tmp).unwrap();
197        let repaired = std::fs::read_to_string(&probe).unwrap();
198        assert!(
199            !repaired.contains("someone edited"),
200            "a drifted file is overwritten from the package"
201        );
202        assert_ne!(mtime(&probe), before, "and its mtime moves");
203        let _ = std::fs::remove_dir_all(&tmp);
204    }
205
206    /// The fingerprint is DERIVED FROM the embedded contents.
207    ///
208    /// Recomputed here with the same FNV-1a `build.rs` uses, so the constant
209    /// cannot drift from the files it claims to describe — a constant that was
210    /// emitted but not actually hashed over the package would pass every other
211    /// test in this file while telling WT.3's staleness check a lie, and the
212    /// symptom would be an artifact that never looks stale no matter what the
213    /// ABI does.
214    #[test]
215    fn the_fingerprint_is_derived_from_the_files() {
216        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
217        let fnv = |bytes: &[u8], h: &mut u64| {
218            for b in bytes {
219                *h ^= u64::from(*b);
220                *h = h.wrapping_mul(0x0000_0100_0000_01b3);
221            }
222        };
223        for (name, contents) in FILES {
224            fnv(name.as_bytes(), &mut hash);
225            fnv(contents.as_bytes(), &mut hash);
226        }
227        assert_eq!(
228            format!("{hash:016x}"),
229            ABI_FINGERPRINT,
230            "the emitted constant is the hash of the embedded package"
231        );
232    }
233
234    /// Sorted, so the fingerprint is a function of content and not of the order
235    /// the filesystem happened to hand the files back. Without this a rebuild
236    /// on another machine could report an ABI change that did not happen.
237    #[test]
238    fn the_package_is_in_sorted_order() {
239        let names: Vec<&str> = file_names().collect();
240        let mut sorted = names.clone();
241        sorted.sort_unstable();
242        assert_eq!(names, sorted);
243    }
244
245    /// The fingerprint is what WT.3 stamps into a built artifact and compares
246    /// at load. It has to be stable within a build, and it has to be a function
247    /// of the package's CONTENT rather than of readdir order — otherwise every
248    /// rebuild would report an ABI change.
249    #[test]
250    fn the_fingerprint_is_stable_and_content_shaped() {
251        assert_eq!(ABI_FINGERPRINT, ABI_FINGERPRINT);
252        assert_eq!(ABI_FINGERPRINT.len(), 16, "a 64-bit hash, hex");
253        assert!(ABI_FINGERPRINT.chars().all(|c| c.is_ascii_hexdigit()));
254        assert_ne!(
255            ABI_FINGERPRINT, "0000000000000000",
256            "the hash actually ran over the files"
257        );
258    }
259}