Skip to main content

lattice_plugin_host/
boundary_event.rs

1//! The event/hook boundary conversions (plugin-host.md §5 `events`, PH7.8a).
2//!
3//! Mirrors `lattice_runtime::EventBus` + the `lattice_protocol::Event` enum —
4//! the observation-only hook surface a plugin subscribes to. Two directions:
5//!
6//!   - **`Event`** crosses **host→guest** (delivered to the guest `on-event`
7//!     export). It is fully owned (no borrows), so it round-trips both ways
8//!     compiler-exhaustively — the honest "every variant covered" guarantee: a
9//!     new `Event` arm cannot land without a mapping here (the `effect` mirror
10//!     precedent). Paths cross as `string`; a non-UTF-8 path is a typed
11//!     boundary error, never a lossy `to_string_lossy` (`path_to_wit`).
12//!
13//!   - **`EventFilter`** crosses **guest→host** (a plugin's `subscribe(filter)`
14//!     at `activate`). It is a ONE-WAY projection (`project_event_filter`): the
15//!     native `EventFilter` cannot round-trip — its `GlobSet` doesn't reverse to
16//!     patterns and its `predicate` is an arbitrary Rust closure. The
17//!     declarative subset (`kinds` / `path-globs` / `major-modes`) crosses; a
18//!     plugin that needs custom logic filters inside its own `on-event` handler
19//!     (the grammar typed-error-defer precedent). This is the reverse of the
20//!     grammar `project_*` fns (there native→WIT; here WIT→native).
21
22use crate::WitBoundary;
23use crate::boundary::path_to_wit;
24use crate::lattice::plugin_host::types::{
25    Event as WitEvent, EventAppliedEdit as WitEventAppliedEdit, EventDocumentChanged,
26    EventDocumentOpened, EventDocumentPath, EventFilter as WitEventFilter,
27    EventKind as WitEventKind, EventModalModeChanged, EventModeLifecycle, EventOptionChanged,
28    EventPlugin as WitEventPlugin, EventPluginLifecycle as WitEventPluginLifecycle,
29    EventSelectionsChanged,
30};
31use lattice_keymap::ModeId;
32use lattice_protocol::event::AppliedEdit as NativeEventAppliedEdit;
33use lattice_protocol::ids::{BufferId, DocumentId};
34use lattice_protocol::position::Range as NativeRange;
35use lattice_protocol::selection::SelectionSet as NativeSelectionSet;
36use lattice_protocol::{Event as NativeEvent, EventKind as NativeEventKind};
37use lattice_runtime::{EventFilter as NativeEventFilter, compile_glob_set};
38
39/// `Option<PathBuf>` → `option<string>`, non-UTF-8 a typed error (mirrors the
40/// private `opt_path_to_wit` in `boundary_effect`; kept local to avoid widening
41/// that module's surface for one caller).
42fn opt_path_to_wit(path: &Option<std::path::PathBuf>) -> Result<Option<String>, String> {
43    path.as_ref().map(|p| path_to_wit(p)).transpose()
44}
45
46impl WitBoundary for NativeEventAppliedEdit {
47    type Wit = WitEventAppliedEdit;
48
49    fn to_wit(&self) -> Result<WitEventAppliedEdit, String> {
50        Ok(WitEventAppliedEdit {
51            original_range: self.original_range.to_wit()?,
52            inserted_range: self.inserted_range.to_wit()?,
53            replaced_text: self.replaced_text.clone(),
54            inserted_text: self.inserted_text.clone(),
55        })
56    }
57
58    fn from_wit(wit: WitEventAppliedEdit) -> Result<Self, String> {
59        Ok(NativeEventAppliedEdit {
60            original_range: NativeRange::from_wit(wit.original_range)?,
61            inserted_range: NativeRange::from_wit(wit.inserted_range)?,
62            replaced_text: wit.replaced_text,
63            inserted_text: wit.inserted_text,
64        })
65    }
66}
67
68impl WitBoundary for NativeEventKind {
69    type Wit = WitEventKind;
70
71    fn to_wit(&self) -> Result<WitEventKind, String> {
72        Ok(match self {
73            NativeEventKind::DocumentOpened => WitEventKind::DocumentOpened,
74            NativeEventKind::DocumentClosed => WitEventKind::DocumentClosed,
75            NativeEventKind::BeforeSave => WitEventKind::BeforeSave,
76            NativeEventKind::DocumentSaved => WitEventKind::DocumentSaved,
77            NativeEventKind::DocumentChanged => WitEventKind::DocumentChanged,
78            NativeEventKind::SelectionsChanged => WitEventKind::SelectionsChanged,
79            NativeEventKind::ModalModeChanged => WitEventKind::ModalModeChanged,
80            NativeEventKind::BeforeQuit => WitEventKind::BeforeQuit,
81            NativeEventKind::OptionChanged => WitEventKind::OptionChanged,
82            NativeEventKind::MajorEntered => WitEventKind::MajorEntered,
83            NativeEventKind::MajorExiting => WitEventKind::MajorExiting,
84            NativeEventKind::MinorActivated => WitEventKind::MinorActivated,
85            NativeEventKind::MinorDeactivated => WitEventKind::MinorDeactivated,
86            NativeEventKind::Plugin => WitEventKind::Plugin,
87            // PH7.12: the host-internal crash/quarantine signal. Not projected to
88            // guests in v1 -- the WIT `event-kind` has no `plugin-crashed`
89            // variant, so a plugin cannot declare it in a filter and this arm is
90            // unreachable in practice. Kept as a typed error (never a silent
91            // drop) to honour the boundary's non-lossy discipline; a Phase-8
92            // monitoring plugin that needs it adds the WIT variant then.
93            NativeEventKind::PluginCrashed => {
94                return Err(
95                    "event-kind `plugin-crashed` is host-internal, not deliverable to plugins"
96                        .to_string(),
97                );
98            }
99            // OA.14d: the awaited pre-load signal, deliverable for the same
100            // reason and to the same subscriber.
101            NativeEventKind::PrePluginLoaded => WitEventKind::PrePluginLoaded,
102            // CI.1: plugin-lifecycle signals ARE deliverable — an init.rs
103            // subscribes to run deferred config.
104            NativeEventKind::PluginLoaded => WitEventKind::PluginLoaded,
105            NativeEventKind::PluginUnloaded => WitEventKind::PluginUnloaded,
106            // CI.4: host-internal enable/disable bridge — the Editor handles it,
107            // never a guest (no WIT variant), like `plugin-crashed`.
108            NativeEventKind::BufferOptionOverrideRequested => {
109                return Err(
110                    "event-kind `buffer-option-override-requested` is host-internal, not \
111                     deliverable to plugins"
112                        .to_string(),
113                );
114            }
115            NativeEventKind::ModeEnablementRequested => {
116                return Err(
117                    "event-kind `mode-enablement-requested` is host-internal, not deliverable to plugins"
118                        .to_string(),
119                );
120            }
121            // MG.41g: host-internal FOR NOW. The design intends plugins
122            // to both publish and receive this (a plugin's async work
123            // should get completion reporting for free), which needs a
124            // WIT variant + payload mirror. Additive when it lands;
125            // erroring here keeps the boundary honest until then rather
126            // than silently dropping the event.
127            NativeEventKind::BackgroundTaskFinished => {
128                return Err(
129                    "event-kind `background-task-finished` is not yet mirrored in WIT".to_string(),
130                );
131            }
132            // OR.2: a plugin's own directory watch fired.
133            NativeEventKind::FilesChanged => WitEventKind::FilesChanged,
134        })
135    }
136
137    fn from_wit(wit: WitEventKind) -> Result<Self, String> {
138        Ok(match wit {
139            WitEventKind::DocumentOpened => NativeEventKind::DocumentOpened,
140            WitEventKind::DocumentClosed => NativeEventKind::DocumentClosed,
141            WitEventKind::BeforeSave => NativeEventKind::BeforeSave,
142            WitEventKind::DocumentSaved => NativeEventKind::DocumentSaved,
143            WitEventKind::DocumentChanged => NativeEventKind::DocumentChanged,
144            WitEventKind::SelectionsChanged => NativeEventKind::SelectionsChanged,
145            WitEventKind::ModalModeChanged => NativeEventKind::ModalModeChanged,
146            WitEventKind::BeforeQuit => NativeEventKind::BeforeQuit,
147            WitEventKind::OptionChanged => NativeEventKind::OptionChanged,
148            WitEventKind::MajorEntered => NativeEventKind::MajorEntered,
149            WitEventKind::MajorExiting => NativeEventKind::MajorExiting,
150            WitEventKind::MinorActivated => NativeEventKind::MinorActivated,
151            WitEventKind::MinorDeactivated => NativeEventKind::MinorDeactivated,
152            WitEventKind::Plugin => NativeEventKind::Plugin,
153            WitEventKind::PrePluginLoaded => NativeEventKind::PrePluginLoaded,
154            WitEventKind::PluginLoaded => NativeEventKind::PluginLoaded,
155            WitEventKind::PluginUnloaded => NativeEventKind::PluginUnloaded,
156            // OR.2: what a guest passes to `subscribe` to hear its own watch.
157            WitEventKind::FilesChanged => NativeEventKind::FilesChanged,
158        })
159    }
160}
161
162impl WitBoundary for NativeEvent {
163    type Wit = WitEvent;
164
165    /// Compiler-exhaustive: a new `Event` arm forces a mapping here. Ids cross
166    /// as `u64` (`.raw()`); paths via `path_to_wit` (non-UTF-8 → typed error).
167    fn to_wit(&self) -> Result<WitEvent, String> {
168        Ok(match self {
169            NativeEvent::DocumentOpened {
170                id,
171                path,
172                version,
173                text,
174            } => WitEvent::DocumentOpened(EventDocumentOpened {
175                id: id.raw(),
176                path: opt_path_to_wit(path)?,
177                version: *version,
178                text: text.clone(),
179            }),
180            NativeEvent::DocumentClosed { id } => WitEvent::DocumentClosed(id.raw()),
181            NativeEvent::BeforeSave { id, path } => WitEvent::BeforeSave(EventDocumentPath {
182                id: id.raw(),
183                path: path_to_wit(path)?,
184            }),
185            NativeEvent::DocumentSaved { id, path } => WitEvent::DocumentSaved(EventDocumentPath {
186                id: id.raw(),
187                path: path_to_wit(path)?,
188            }),
189            NativeEvent::DocumentChanged {
190                id,
191                path,
192                version,
193                edits,
194            } => WitEvent::DocumentChanged(EventDocumentChanged {
195                id: id.raw(),
196                path: opt_path_to_wit(path)?,
197                version: *version,
198                edits: edits
199                    .iter()
200                    .map(WitBoundary::to_wit)
201                    .collect::<Result<Vec<_>, _>>()?,
202            }),
203            NativeEvent::SelectionsChanged {
204                id,
205                version,
206                selections,
207            } => WitEvent::SelectionsChanged(EventSelectionsChanged {
208                id: id.raw(),
209                version: *version,
210                selections: selections.to_wit()?,
211            }),
212            NativeEvent::ModalModeChanged { from, to } => {
213                WitEvent::ModalModeChanged(EventModalModeChanged {
214                    from_state: from.clone(),
215                    to_state: to.clone(),
216                })
217            }
218            NativeEvent::BeforeQuit => WitEvent::BeforeQuit,
219            NativeEvent::OptionChanged { name, old, new } => {
220                WitEvent::OptionChanged(EventOptionChanged {
221                    name: name.clone(),
222                    old: old.clone(),
223                    new_value: new.clone(),
224                })
225            }
226            NativeEvent::MajorEntered { buffer, major } => {
227                WitEvent::MajorEntered(mode_lifecycle(buffer, major))
228            }
229            NativeEvent::MajorExiting { buffer, major } => {
230                WitEvent::MajorExiting(mode_lifecycle(buffer, major))
231            }
232            NativeEvent::MinorActivated { buffer, minor } => {
233                WitEvent::MinorActivated(mode_lifecycle(buffer, minor))
234            }
235            NativeEvent::MinorDeactivated { buffer, minor } => {
236                WitEvent::MinorDeactivated(mode_lifecycle(buffer, minor))
237            }
238            // Opaque bytes cross verbatim — the host is a thin router (PH7.8b).
239            NativeEvent::Plugin { name, payload } => WitEvent::Plugin(WitEventPlugin {
240                name: name.clone(),
241                payload: payload.clone(),
242            }),
243            // PH7.12: host-internal crash/quarantine signal — never routed to a
244            // guest (no WIT variant to subscribe to), so this is unreachable in
245            // practice; a typed error keeps the boundary non-lossy.
246            NativeEvent::PluginCrashed { .. } => {
247                return Err(
248                    "event `plugin-crashed` is host-internal, not deliverable to plugins"
249                        .to_string(),
250                );
251            }
252            // OA.14d: name only — see the WIT arm for why no id crosses.
253            NativeEvent::PrePluginLoaded { name } => WitEvent::PrePluginLoaded(name.clone()),
254            // CI.1: plugin-lifecycle signals ARE delivered to guests.
255            NativeEvent::PluginLoaded { name, id } => {
256                WitEvent::PluginLoaded(WitEventPluginLifecycle {
257                    name: name.clone(),
258                    id: *id,
259                })
260            }
261            NativeEvent::PluginUnloaded { name, id } => {
262                WitEvent::PluginUnloaded(WitEventPluginLifecycle {
263                    name: name.clone(),
264                    id: *id,
265                })
266            }
267            // CI.4: host-internal — never routed to a guest.
268            NativeEvent::BufferOptionOverrideRequested { .. } => {
269                return Err(
270                    "event `buffer-option-override-requested` is host-internal, not \
271                     deliverable to plugins"
272                        .to_string(),
273                );
274            }
275            NativeEvent::ModeEnablementRequested { .. } => {
276                return Err(
277                    "event `mode-enablement-requested` is host-internal, not deliverable to plugins"
278                        .to_string(),
279                );
280            }
281            // MG.41g: see the `EventKind` arm above.
282            NativeEvent::BackgroundTaskFinished { .. } => {
283                return Err(
284                    "event `background-task-finished` is not yet mirrored in WIT".to_string(),
285                );
286            }
287            // OR.2. The plugin id does NOT cross: the delivery actor has
288            // already established that this batch belongs to the guest it is
289            // about to hand it to, so sending the id would be telling a plugin
290            // its own name. A non-UTF-8 path is SKIPPED rather than failing the
291            // batch — `walk`'s rule, for `walk`'s reason: one oddly-named file
292            // must not cost an index every other file in the same burst.
293            NativeEvent::FilesChanged { paths, .. } => WitEvent::FilesChanged(
294                paths
295                    .iter()
296                    .filter_map(|p| p.to_str().map(str::to_string))
297                    .collect(),
298            ),
299        })
300    }
301
302    fn from_wit(wit: WitEvent) -> Result<Self, String> {
303        Ok(match wit {
304            WitEvent::DocumentOpened(p) => NativeEvent::DocumentOpened {
305                id: DocumentId::new(p.id),
306                path: p.path.map(std::path::PathBuf::from),
307                version: p.version,
308                text: p.text,
309            },
310            WitEvent::DocumentClosed(id) => NativeEvent::DocumentClosed {
311                id: DocumentId::new(id),
312            },
313            WitEvent::BeforeSave(p) => NativeEvent::BeforeSave {
314                id: DocumentId::new(p.id),
315                path: std::path::PathBuf::from(p.path),
316            },
317            WitEvent::DocumentSaved(p) => NativeEvent::DocumentSaved {
318                id: DocumentId::new(p.id),
319                path: std::path::PathBuf::from(p.path),
320            },
321            WitEvent::DocumentChanged(p) => NativeEvent::DocumentChanged {
322                id: DocumentId::new(p.id),
323                path: p.path.map(std::path::PathBuf::from),
324                version: p.version,
325                edits: p
326                    .edits
327                    .into_iter()
328                    .map(NativeEventAppliedEdit::from_wit)
329                    .collect::<Result<Vec<_>, _>>()?,
330            },
331            WitEvent::SelectionsChanged(p) => NativeEvent::SelectionsChanged {
332                id: DocumentId::new(p.id),
333                version: p.version,
334                selections: NativeSelectionSet::from_wit(p.selections)?,
335            },
336            WitEvent::ModalModeChanged(p) => NativeEvent::ModalModeChanged {
337                from: p.from_state,
338                to: p.to_state,
339            },
340            WitEvent::BeforeQuit => NativeEvent::BeforeQuit,
341            WitEvent::OptionChanged(p) => NativeEvent::OptionChanged {
342                name: p.name,
343                old: p.old,
344                new: p.new_value,
345            },
346            WitEvent::MajorEntered(p) => NativeEvent::MajorEntered {
347                buffer: BufferId::new(p.buffer),
348                major: p.mode,
349            },
350            WitEvent::MajorExiting(p) => NativeEvent::MajorExiting {
351                buffer: BufferId::new(p.buffer),
352                major: p.mode,
353            },
354            WitEvent::MinorActivated(p) => NativeEvent::MinorActivated {
355                buffer: BufferId::new(p.buffer),
356                minor: p.mode,
357            },
358            WitEvent::MinorDeactivated(p) => NativeEvent::MinorDeactivated {
359                buffer: BufferId::new(p.buffer),
360                minor: p.mode,
361            },
362            WitEvent::Plugin(p) => NativeEvent::Plugin {
363                name: p.name,
364                payload: p.payload,
365            },
366            WitEvent::PrePluginLoaded(name) => NativeEvent::PrePluginLoaded { name },
367            WitEvent::PluginLoaded(p) => NativeEvent::PluginLoaded {
368                name: p.name,
369                id: p.id,
370            },
371            WitEvent::PluginUnloaded(p) => NativeEvent::PluginUnloaded {
372                name: p.name,
373                id: p.id,
374            },
375            // OR.2. A guest never publishes one of these — the host originates
376            // every watch batch — so `plugin` has no source but `0`. That is
377            // the honest value rather than a fabricated one: an unaddressed
378            // batch matches no actor's id and is therefore delivered to nobody,
379            // which is the correct outcome for an event a guest invented.
380            WitEvent::FilesChanged(paths) => NativeEvent::FilesChanged {
381                plugin: 0,
382                paths: paths.into_iter().map(std::path::PathBuf::from).collect(),
383            },
384        })
385    }
386}
387
388/// The shared `major/minor` lifecycle payload — buffer id + the mode's
389/// canonical name (the four lifecycle arms share one WIT record).
390fn mode_lifecycle(buffer: &BufferId, mode: &str) -> EventModeLifecycle {
391    EventModeLifecycle {
392        buffer: buffer.raw(),
393        mode: mode.to_string(),
394    }
395}
396
397/// Project a guest-supplied [`WitEventFilter`] into the native
398/// [`EventFilter`](NativeEventFilter) (guest→host, one-way). The declarative
399/// fields cross; the native `predicate` is always `None` (a Rust closure can't
400/// cross — the guest filters in its `on-event` handler instead). `kinds = none`
401/// stays the wildcard; `path-globs` compile via the same `compile_glob_set` the
402/// native subscribers use, so a plugin filter matches identically to a native
403/// one.
404pub fn project_event_filter(wit: WitEventFilter) -> Result<NativeEventFilter, String> {
405    let kinds = wit
406        .kinds
407        .map(|ks| {
408            ks.into_iter()
409                .map(NativeEventKind::from_wit)
410                .collect::<Result<Vec<_>, _>>()
411        })
412        .transpose()?;
413    Ok(NativeEventFilter {
414        kinds,
415        path_glob: wit.path_globs.map(compile_glob_set),
416        major_modes: wit
417            .major_modes
418            .map(|ms| ms.iter().map(|m| ModeId::new(m)).collect()),
419        minor_modes: wit
420            .minor_modes
421            .map(|ms| ms.iter().map(|m| ModeId::new(m)).collect()),
422        predicate: None,
423    })
424}
425
426#[cfg(test)]
427mod tests {
428    #![allow(clippy::unwrap_used, clippy::panic)]
429
430    use super::*;
431    use lattice_protocol::position::Position;
432    use lattice_protocol::selection::{Selection, SelectionSet};
433
434    fn pos(line: u32, byte: u32) -> Position {
435        Position { line, byte }
436    }
437
438    fn rng(l0: u32, b0: u32, l1: u32, b1: u32) -> NativeRange {
439        NativeRange {
440            start: pos(l0, b0),
441            end: pos(l1, b1),
442        }
443    }
444
445    /// Round-trip an event through WIT and assert equality of its debug form —
446    /// `Event` isn't `PartialEq`, so we compare the structural debug rendering,
447    /// which is faithful for these owned payloads.
448    fn round_trip(ev: NativeEvent) {
449        let wit = ev.to_wit().unwrap();
450        let back = NativeEvent::from_wit(wit).unwrap();
451        assert_eq!(format!("{ev:?}"), format!("{back:?}"));
452    }
453
454    #[test]
455    fn document_lifecycle_arms_round_trip() {
456        round_trip(NativeEvent::DocumentOpened {
457            id: DocumentId::new(7),
458            path: Some(std::path::PathBuf::from("src/lib.rs")),
459            version: 3,
460            text: "fn main() {}\n".into(),
461        });
462        round_trip(NativeEvent::DocumentOpened {
463            id: DocumentId::new(8),
464            path: None, // scratch buffer
465            version: 0,
466            text: String::new(),
467        });
468        round_trip(NativeEvent::DocumentClosed {
469            id: DocumentId::new(7),
470        });
471        round_trip(NativeEvent::BeforeSave {
472            id: DocumentId::new(1),
473            path: std::path::PathBuf::from("/tmp/a.rs"),
474        });
475        round_trip(NativeEvent::DocumentSaved {
476            id: DocumentId::new(1),
477            path: std::path::PathBuf::from("/tmp/a.rs"),
478        });
479    }
480
481    #[test]
482    fn document_changed_carries_edits_without_delta() {
483        round_trip(NativeEvent::DocumentChanged {
484            id: DocumentId::new(2),
485            path: Some(std::path::PathBuf::from("x.rs")),
486            version: 4,
487            edits: vec![NativeEventAppliedEdit {
488                original_range: rng(0, 0, 0, 3),
489                inserted_range: rng(0, 0, 0, 5),
490                replaced_text: "abc".into(),
491                inserted_text: "hello".into(),
492            }],
493        });
494    }
495
496    #[test]
497    fn selections_changed_round_trips_the_selection_set() {
498        let set = SelectionSet::from_parts(
499            vec![Selection {
500                anchor: pos(0, 0),
501                head: pos(0, 4),
502                visual: None,
503            }],
504            0,
505        );
506        round_trip(NativeEvent::SelectionsChanged {
507            id: DocumentId::new(3),
508            version: 9,
509            selections: set,
510        });
511    }
512
513    #[test]
514    fn scalar_arms_round_trip() {
515        round_trip(NativeEvent::ModalModeChanged {
516            from: "Normal".into(),
517            to: "Insert".into(),
518        });
519        round_trip(NativeEvent::BeforeQuit);
520        round_trip(NativeEvent::OptionChanged {
521            name: "wrap".into(),
522            old: Some("off".into()),
523            new: "on".into(),
524        });
525        round_trip(NativeEvent::OptionChanged {
526            name: "wrap".into(),
527            old: None, // first publish after registration
528            new: "off".into(),
529        });
530    }
531
532    #[test]
533    fn mode_lifecycle_arms_round_trip() {
534        for ev in [
535            NativeEvent::MajorEntered {
536                buffer: BufferId::new(5),
537                major: "rust-mode".into(),
538            },
539            NativeEvent::MajorExiting {
540                buffer: BufferId::new(5),
541                major: "rust-mode".into(),
542            },
543            NativeEvent::MinorActivated {
544                buffer: BufferId::new(5),
545                minor: "diff-mode".into(),
546            },
547            NativeEvent::MinorDeactivated {
548                buffer: BufferId::new(5),
549                minor: "diff-mode".into(),
550            },
551        ] {
552            round_trip(ev);
553        }
554    }
555
556    #[test]
557    fn event_kind_round_trips_every_arm() {
558        for kind in [
559            NativeEventKind::DocumentOpened,
560            NativeEventKind::DocumentClosed,
561            NativeEventKind::BeforeSave,
562            NativeEventKind::DocumentSaved,
563            NativeEventKind::DocumentChanged,
564            NativeEventKind::SelectionsChanged,
565            NativeEventKind::ModalModeChanged,
566            NativeEventKind::BeforeQuit,
567            NativeEventKind::OptionChanged,
568            NativeEventKind::MajorEntered,
569            NativeEventKind::MajorExiting,
570            NativeEventKind::MinorActivated,
571            NativeEventKind::MinorDeactivated,
572            NativeEventKind::Plugin,
573        ] {
574            assert_eq!(
575                NativeEventKind::from_wit(kind.to_wit().unwrap()).unwrap(),
576                kind
577            );
578        }
579    }
580
581    #[test]
582    fn plugin_event_round_trips_opaque_payload() {
583        // The host is a thin router: the name + arbitrary bytes cross verbatim,
584        // including a non-UTF-8 payload (MessagePack is binary, not text).
585        round_trip(NativeEvent::Plugin {
586            name: "git-gutter.hunks-changed".into(),
587            payload: vec![0x00, 0x91, 0xff, 0x7f, 0xde],
588        });
589        round_trip(NativeEvent::Plugin {
590            name: "empty".into(),
591            payload: Vec::new(),
592        });
593    }
594
595    #[test]
596    fn non_utf8_path_is_a_typed_error() {
597        #[cfg(unix)]
598        {
599            use std::os::unix::ffi::OsStrExt;
600            let bad = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(b"/tmp/\xff.rs"));
601            let ev = NativeEvent::DocumentSaved {
602                id: DocumentId::new(1),
603                path: bad,
604            };
605            assert!(
606                ev.to_wit().is_err(),
607                "non-UTF-8 path must not cross lossily"
608            );
609        }
610    }
611
612    #[test]
613    fn project_event_filter_crosses_declarative_fields() {
614        let wit = WitEventFilter {
615            kinds: Some(vec![WitEventKind::DocumentSaved, WitEventKind::BeforeQuit]),
616            path_globs: Some(vec!["**/*.rs".into()]),
617            major_modes: Some(vec!["rust-mode".into()]),
618            minor_modes: Some(vec!["auto-pair-mode".into()]),
619        };
620        let native = project_event_filter(wit).unwrap();
621        let kinds = native.kinds.expect("kinds crossed");
622        assert_eq!(kinds.len(), 2);
623        assert!(kinds.contains(&NativeEventKind::DocumentSaved));
624        assert!(native.path_glob.is_some(), "path glob compiled");
625        let modes = native.major_modes.expect("major modes crossed");
626        assert_eq!(modes[0].as_str(), "rust-mode");
627        // Populated on purpose: a `None` here would round-trip clean through
628        // an arm that dropped the field, and a guest whose minor filter
629        // arrived empty gets woken for every minor in every buffer — the
630        // exact cost the field exists to remove.
631        let minors = native.minor_modes.expect("minor modes crossed");
632        assert_eq!(minors[0].as_str(), "auto-pair-mode");
633        // The predicate never crosses — a plugin filters in `on-event`.
634        assert!(native.predicate.is_none());
635    }
636
637    #[test]
638    fn project_event_filter_wildcard_is_unconstrained() {
639        let wit = WitEventFilter {
640            kinds: None,
641            path_globs: None,
642            major_modes: None,
643            minor_modes: None,
644        };
645        let native = project_event_filter(wit).unwrap();
646        assert!(native.kinds.is_none(), "none kinds stays the wildcard");
647        assert!(native.path_glob.is_none());
648        assert!(native.major_modes.is_none());
649    }
650}