lattice_plugin_host/buffer.rs
1//! The `document` resource backing + the `buffer-snapshot` projection
2//! (plugin-host.md §4.2 / §9.6, PH7.3c).
3//!
4//! §4.2's borrows (`&Buffer`, `&Path`, `&str` inside `ActiveBufferSnapshot`)
5//! cannot cross the WASM boundary. The host projects the *metadata* into an
6//! owned [`buffer::BufferSnapshot`] record ([`project_buffer_snapshot`]) and
7//! hands the guest a `document` **resource handle** for the text. Bulk rope
8//! text never rides the snapshot: the guest calls `get-text-range(range)` and
9//! the host slices only that range out of the rope ("zero-copy at the slice
10//! level" — the bytes still cross into guest linear memory, but the whole
11//! document never does).
12//!
13//! **Decision A (locked):** the resource is backed by an
14//! `Arc<DocumentSnapshot>` — a point-in-time immutable view. Edits landing
15//! after the handle is minted never shift byte ranges under the guest mid-read.
16//!
17//! The end-to-end guest→host call through the canonical ABI is exercised at
18//! PH7.3d/PH7.4 (the call machinery + the <500ns bench live there); this slice
19//! proves the design at the host layer — the resource is real (wired into the
20//! linker via bindgen's generated `add_to_linker`) and its methods + projection
21//! are unit-tested directly.
22
23use std::sync::Arc;
24
25use lattice_picker::context::ActiveBufferSnapshot;
26use lattice_protocol::position::Range as NativeRange;
27use lattice_runtime::snapshot::DocumentSnapshot;
28
29use crate::WitBoundary;
30use crate::boundary::path_to_wit;
31use crate::lattice::plugin_host::buffer::BufferSnapshot as WitBufferSnapshot;
32
33/// Host-side backing for the `document` WIT resource (decision A): a
34/// point-in-time immutable snapshot. The bindgen `with:` mapping makes this the
35/// resource representation, so the `Store`'s `ResourceTable` stores it directly
36/// and [`HostDocument`] methods receive `Resource<DocumentResource>`.
37pub struct DocumentResource {
38 snapshot: Arc<DocumentSnapshot>,
39}
40
41impl DocumentResource {
42 /// Wrap a document snapshot as a resource backing.
43 pub fn new(snapshot: Arc<DocumentSnapshot>) -> Self {
44 Self { snapshot }
45 }
46
47 /// The text of the `[start, end)` byte range. Slices only the requested
48 /// range out of the rope; the whole document is never materialised. `Err`
49 /// on an out-of-range or `end < start` range (mirrors `Buffer::slice`).
50 pub fn get_text_range(&self, range: NativeRange) -> Result<String, String> {
51 self.snapshot.buffer.slice(range).map_err(|e| e.to_string())
52 }
53
54 /// Lines the document has, in the sense the WIT contract means:
55 /// `"a\nb\n"` is two lines.
56 ///
57 /// CV.3: content space. This surfaced ropey's raw count, so a
58 /// guest iterating `0..line-count` and calling `line(n)` got one
59 /// phantom empty line at the end of every normal file — a
60 /// rope-implementation detail leaking across the plugin boundary,
61 /// which every guest author would then have to rediscover and
62 /// correct for.
63 pub fn line_count(&self) -> u32 {
64 self.snapshot.buffer.content_line_count()
65 }
66
67 /// Total byte length.
68 pub fn byte_len(&self) -> u64 {
69 self.snapshot.buffer.byte_len()
70 }
71
72 /// Line `n` (0-based) without its trailing newline (matching
73 /// `Buffer::line`), or `None` past EOF.
74 pub fn line_at(&self, n: u32) -> Option<String> {
75 self.snapshot.buffer.line(n)
76 }
77
78 /// OM.6b: the file backing this document, absolute; `None` for a buffer
79 /// with no path on disk.
80 ///
81 /// A non-UTF-8 path also answers `None` rather than erroring. The WIT
82 /// signature has no error channel on purpose — the guest's question is
83 /// "can I name a file next to mine", and "no" is a complete answer to it.
84 /// Erroring would make one oddly-named file fail an action that has
85 /// nothing to do with encoding.
86 pub fn path(&self) -> Option<String> {
87 self.snapshot
88 .path
89 .as_deref()
90 .and_then(|p| p.to_str())
91 .map(str::to_string)
92 }
93}
94
95/// Project the borrow-carrying [`ActiveBufferSnapshot`] into the owned WIT
96/// [`buffer::BufferSnapshot`] metadata record (§4.2). Bulk text is NOT copied
97/// here — it rides the `document` handle. A non-UTF-8 path is a typed error
98/// (never lossy), matching the boundary convention.
99pub fn project_buffer_snapshot(snap: &ActiveBufferSnapshot) -> Result<WitBufferSnapshot, String> {
100 Ok(WitBufferSnapshot {
101 buffer_id: snap.buffer_id,
102 path: snap.path.map(path_to_wit).transpose()?,
103 language: snap.language.map(str::to_string),
104 cursor: snap.cursor.to_wit()?,
105 selection: snap
106 .selection
107 .map(|(anchor, head)| Ok::<_, String>((anchor.to_wit()?, head.to_wit()?)))
108 .transpose()?,
109 })
110}
111
112// NB: the generated `buffer::HostDocument` host trait (the guest's `document`
113// method calls) is wired to `PluginState` at **AP.0.1** — the grammar
114// `apply-action(…, doc: borrow<document>)` signature is the first world function
115// to reference the resource (bindgen only binds a `with`-mapped resource a world
116// function uses). The impl + `add_to_linker` (on the SYNC grammar linker) + the
117// `with`-mapping (`"lattice:plugin-host/buffer.document"`) live in `lib.rs` /
118// `grammar_host.rs`; the trampoline (`grammar_trampoline.rs`) mints + lends the
119// handle per dispatch. The backing type below stays the single source of the
120// slice/metadata logic both this and any future consumer (picker `init(doc)`)
121// forward to.
122
123#[cfg(test)]
124mod tests {
125 #![allow(clippy::unwrap_used, clippy::panic)]
126
127 use super::*;
128 use lattice_core::buffer::Buffer;
129 use lattice_protocol::position::Position;
130
131 fn snapshot(text: &str) -> Arc<DocumentSnapshot> {
132 Arc::new(DocumentSnapshot {
133 buffer: Buffer::from_text(text),
134 ..Default::default()
135 })
136 }
137
138 fn pos(line: u32, byte: u32) -> Position {
139 Position { line, byte }
140 }
141
142 #[test]
143 fn get_text_range_slices_only_the_requested_span() {
144 let doc = DocumentResource::new(snapshot("hello\nworld\n"));
145 // "world" is line 1, bytes 0..5.
146 let got = doc
147 .get_text_range(NativeRange {
148 start: pos(1, 0),
149 end: pos(1, 5),
150 })
151 .unwrap();
152 assert_eq!(got, "world");
153 }
154
155 #[test]
156 fn get_text_range_out_of_range_is_a_typed_error() {
157 let doc = DocumentResource::new(snapshot("hi\n"));
158 // Line 9 doesn't exist.
159 let err = doc
160 .get_text_range(NativeRange {
161 start: pos(9, 0),
162 end: pos(9, 1),
163 })
164 .expect_err("out-of-range must be a typed error");
165 assert!(!err.is_empty(), "error carries a message: {err}");
166 }
167
168 #[test]
169 fn get_text_range_end_before_start_is_a_typed_error() {
170 let doc = DocumentResource::new(snapshot("abcdef\n"));
171 let err = doc
172 .get_text_range(NativeRange {
173 start: pos(0, 4),
174 end: pos(0, 1),
175 })
176 .expect_err("end < start must be a typed error");
177 assert!(!err.is_empty(), "error carries a message: {err}");
178 }
179
180 #[test]
181 fn metadata_readers_match_the_buffer() {
182 let doc = DocumentResource::new(snapshot("a\nbb\nccc\n"));
183 // Three lines. The trailing newline TERMINATES the third line rather
184 // than opening a fourth empty one — this asserted 4 until the buffer's
185 // line counting was corrected, and the stale expectation outlived the
186 // fix. A plugin addressing `line_count - 1` as "the last line" must
187 // land on `ccc`, not on a phantom line past the end.
188 assert_eq!(doc.line_count(), 3);
189 assert_eq!(doc.byte_len(), 9);
190 // `Buffer::line` strips the trailing newline.
191 assert_eq!(doc.line_at(1).as_deref(), Some("bb"));
192 assert_eq!(doc.line_at(2).as_deref(), Some("ccc"));
193 // KNOWN INCONSISTENCY, pinned so it is visible rather than tolerated:
194 // `line_count()` says 3, yet index 3 still reads as an empty line. The
195 // two disagree about whether the trailing newline opens a line. A
196 // plugin that iterates `0..line_count()` is unaffected, which is why
197 // this has gone unnoticed; one that probes `line_at` directly sees a
198 // line the count denies. If this is ever reconciled, expect `None`
199 // here and delete this comment.
200 assert_eq!(doc.line_at(3).as_deref(), Some(""));
201 assert_eq!(doc.line_at(99), None);
202 }
203
204 /// Decision A: the resource is a point-in-time snapshot. A later, unrelated
205 /// snapshot of "the same document" does not shift ranges under a handle
206 /// minted from the earlier one.
207 #[test]
208 fn snapshot_backing_is_immutable_under_later_edits() {
209 let original = snapshot("original text\n");
210 let doc = DocumentResource::new(original);
211 // A subsequent edit produces a *new* snapshot; the handle still reads
212 // the one it was minted from.
213 let _later = snapshot("edited!\n");
214 let got = doc
215 .get_text_range(NativeRange {
216 start: pos(0, 0),
217 end: pos(0, 8),
218 })
219 .unwrap();
220 assert_eq!(got, "original");
221 }
222
223 #[test]
224 fn project_buffer_snapshot_projects_metadata_not_text() {
225 let buffer = Buffer::from_text("fn main() {}\n");
226 let path = std::path::PathBuf::from("/proj/src/main.rs");
227 let snap = ActiveBufferSnapshot {
228 buffer_id: 7,
229 path: Some(path.as_path()),
230 language: Some("rust"),
231 cursor: pos(0, 3),
232 selection: Some((pos(0, 0), pos(0, 7))),
233 buffer: &buffer,
234 syntax_symbols: Vec::new(),
235 syntax_highlights: Vec::new(),
236 };
237 let wit = project_buffer_snapshot(&snap).unwrap();
238 assert_eq!(wit.buffer_id, 7);
239 assert_eq!(wit.path.as_deref(), Some("/proj/src/main.rs"));
240 assert_eq!(wit.language.as_deref(), Some("rust"));
241 assert_eq!(wit.cursor.line, 0);
242 assert_eq!(wit.cursor.byte, 3);
243 let (a, b) = wit.selection.expect("selection present");
244 assert_eq!((a.byte, b.byte), (0, 7));
245 }
246
247 #[test]
248 fn project_buffer_snapshot_handles_absent_optionals() {
249 let buffer = Buffer::from_text("scratch\n");
250 let snap = ActiveBufferSnapshot {
251 buffer_id: 1,
252 path: None,
253 language: None,
254 cursor: pos(0, 0),
255 selection: None,
256 buffer: &buffer,
257 syntax_symbols: Vec::new(),
258 syntax_highlights: Vec::new(),
259 };
260 let wit = project_buffer_snapshot(&snap).unwrap();
261 assert!(wit.path.is_none());
262 assert!(wit.language.is_none());
263 assert!(wit.selection.is_none());
264 }
265}