Skip to main content

par_term_scripting/
observer.rs

1//! Observer bridge that converts core library `TerminalEvent`s into scripting `ScriptEvent`s.
2//!
3//! [`ScriptEventForwarder`] implements the `TerminalObserver` trait from
4//! `par-term-emu-core-rust`.  It captures events into a thread-safe buffer
5//! that the main event loop drains and forwards to script sub-processes.
6
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::sync::Mutex;
9
10use par_term_emu_core_rust::observer::TerminalObserver;
11use par_term_emu_core_rust::terminal::TerminalEvent;
12
13use super::protocol::{ScriptEvent, ScriptEventData};
14
15/// Maximum events retained in a forwarder's buffer.
16///
17/// The buffer is filled from the PTY reader thread and emptied by the event
18/// loop, so anything that stops the loop draining a particular forwarder makes
19/// it grow for the life of the process. A script that exits on its own leaves
20/// its observer registered — only an explicit stop unregisters it — so that
21/// case is reachable no matter how thoroughly the event loop sweeps its tabs,
22/// and this cap is the only thing bounding it.
23const MAX_BUFFERED_EVENTS: usize = 1024;
24
25/// The complete subscribable event-kind vocabulary: every snake_case kind
26/// name [`ScriptEventForwarder::event_kind_name`] can produce, plus the
27/// app-sourced kinds the host itself delivers (marked below — they never
28/// come from a terminal observer, so the forwarder's filter can never match
29/// them; the plugin host gates them by manifest subscription instead).
30///
31/// The authority for validating a plugin manifest's `subscriptions` — a
32/// subscription naming anything outside this list can never fire, so
33/// discovery rejects it rather than letting it sit inert. Keep the
34/// terminal-sourced part in sync with `event_kind_name`'s match: a new
35/// `TerminalEvent` arm must add its kind here in the same change.
36pub const EVENT_KINDS: &[&str] = &[
37    "bell_rang",
38    "title_changed",
39    "size_changed",
40    "mode_changed",
41    "graphics_added",
42    "hyperlink_added",
43    "dirty_region",
44    "cwd_changed",
45    "trigger_matched",
46    "user_var_changed",
47    "progress_bar_changed",
48    "badge_changed",
49    "command_complete",
50    "zone_opened",
51    "zone_closed",
52    "zone_scrolled_out",
53    "environment_changed",
54    "remote_host_transition",
55    "sub_shell_detected",
56    "file_transfer_started",
57    "file_transfer_progress",
58    "file_transfer_completed",
59    "file_transfer_failed",
60    "upload_requested",
61    "screen_cleared",
62    // App-sourced (delivered host-side, not by a terminal observer):
63    "theme_changed",
64];
65
66/// The buffered events plus the once-only overflow latch, behind one lock.
67struct EventBuffer {
68    events: VecDeque<ScriptEvent>,
69    /// Set the first time the cap is reached, so overflow is reported once per
70    /// forwarder instead of once per discarded event. Never cleared: a drain
71    /// does not mean the underlying leak was fixed, and re-arming would let a
72    /// forwarder that overflows and drains repeatedly flood the log.
73    overflow_reported: bool,
74}
75
76/// Bridge between core terminal observer events and the scripting JSON protocol.
77///
78/// Register an `Arc<ScriptEventForwarder>` with `Terminal::add_observer()`.
79/// The forwarder buffers converted events; the owner drains them via
80/// [`drain_events`] and serialises them to script sub-processes.
81pub struct ScriptEventForwarder {
82    /// Optional subscription filter expressed as snake_case kind names.
83    /// `None` means "forward everything".
84    subscription_filter: Option<HashSet<String>>,
85    /// Thread-safe event buffer (uses std Mutex since observer callbacks are
86    /// invoked from the PTY reader thread), capped at [`MAX_BUFFERED_EVENTS`].
87    event_buffer: Mutex<EventBuffer>,
88}
89
90impl ScriptEventForwarder {
91    /// Create a new forwarder.
92    ///
93    /// # Arguments
94    /// * `subscriptions` - If `Some`, only events whose snake_case kind name
95    ///   is in the set will be captured. If `None`, all events are captured.
96    pub fn new(subscriptions: Option<HashSet<String>>) -> Self {
97        Self {
98            subscription_filter: subscriptions,
99            event_buffer: Mutex::new(EventBuffer {
100                events: VecDeque::new(),
101                overflow_reported: false,
102            }),
103        }
104    }
105
106    /// Drain all buffered events, returning them and clearing the buffer.
107    pub fn drain_events(&self) -> Vec<ScriptEvent> {
108        let mut buf = self.event_buffer.lock().expect("event_buffer poisoned");
109        buf.events.drain(..).collect()
110    }
111
112    /// Map a `TerminalEvent` to its snake_case kind name used by the protocol.
113    fn event_kind_name(event: &TerminalEvent) -> String {
114        match event {
115            TerminalEvent::BellRang(_) => "bell_rang".to_string(),
116            TerminalEvent::TitleChanged(_) => "title_changed".to_string(),
117            TerminalEvent::SizeChanged(_, _) => "size_changed".to_string(),
118            TerminalEvent::ModeChanged(_, _) => "mode_changed".to_string(),
119            TerminalEvent::GraphicsAdded(_) => "graphics_added".to_string(),
120            TerminalEvent::HyperlinkAdded { .. } => "hyperlink_added".to_string(),
121            TerminalEvent::DirtyRegion(_, _) => "dirty_region".to_string(),
122            TerminalEvent::CwdChanged(_) => "cwd_changed".to_string(),
123            TerminalEvent::TriggerMatched(_) => "trigger_matched".to_string(),
124            TerminalEvent::UserVarChanged { .. } => "user_var_changed".to_string(),
125            TerminalEvent::ProgressBarChanged { .. } => "progress_bar_changed".to_string(),
126            TerminalEvent::BadgeChanged(_) => "badge_changed".to_string(),
127            TerminalEvent::ShellIntegrationEvent { .. } => "command_complete".to_string(),
128            TerminalEvent::ZoneOpened { .. } => "zone_opened".to_string(),
129            TerminalEvent::ZoneClosed { .. } => "zone_closed".to_string(),
130            TerminalEvent::ZoneScrolledOut { .. } => "zone_scrolled_out".to_string(),
131            TerminalEvent::EnvironmentChanged { .. } => "environment_changed".to_string(),
132            TerminalEvent::RemoteHostTransition { .. } => "remote_host_transition".to_string(),
133            TerminalEvent::SubShellDetected { .. } => "sub_shell_detected".to_string(),
134            TerminalEvent::FileTransferStarted { .. } => "file_transfer_started".to_string(),
135            TerminalEvent::FileTransferProgress { .. } => "file_transfer_progress".to_string(),
136            TerminalEvent::FileTransferCompleted { .. } => "file_transfer_completed".to_string(),
137            TerminalEvent::FileTransferFailed { .. } => "file_transfer_failed".to_string(),
138            TerminalEvent::UploadRequested { .. } => "upload_requested".to_string(),
139            TerminalEvent::ScreenCleared { .. } => "screen_cleared".to_string(),
140
141            // Core variants this mapping predates (InlineImageDropped
142            // arrived with the local 0.50 line) and any future ones. A
143            // named arm per variant would fail compilation against the
144            // published 0.49 pin, and a plain wildcard is unreachable
145            // there (an error under -D warnings) — so the arm carries the
146            // allow, the one form valid on both versions (same shape as
147            // the ParserBridge tolerance, 67211ee3).
148            #[allow(unreachable_patterns)]
149            _ => "unknown".to_string(),
150        }
151    }
152
153    /// Convert a core `TerminalEvent` into the scripting protocol `ScriptEvent`.
154    fn convert_event(event: &TerminalEvent) -> ScriptEvent {
155        let kind = Self::event_kind_name(event);
156
157        let data = match event {
158            TerminalEvent::BellRang(_) => ScriptEventData::Empty {},
159
160            TerminalEvent::TitleChanged(title) => ScriptEventData::TitleChanged {
161                title: title.clone(),
162            },
163
164            TerminalEvent::SizeChanged(cols, rows) => ScriptEventData::SizeChanged {
165                cols: *cols,
166                rows: *rows,
167            },
168
169            TerminalEvent::CwdChanged(cwd_change) => ScriptEventData::CwdChanged {
170                cwd: cwd_change.new_cwd.clone(),
171            },
172
173            TerminalEvent::UserVarChanged {
174                name,
175                value,
176                old_value,
177            } => ScriptEventData::VariableChanged {
178                name: name.clone(),
179                value: value.clone(),
180                old_value: old_value.clone(),
181            },
182
183            TerminalEvent::EnvironmentChanged {
184                key,
185                value,
186                old_value,
187            } => ScriptEventData::EnvironmentChanged {
188                key: key.clone(),
189                value: value.clone(),
190                old_value: old_value.clone(),
191            },
192
193            TerminalEvent::BadgeChanged(text) => {
194                ScriptEventData::BadgeChanged { text: text.clone() }
195            }
196
197            TerminalEvent::ShellIntegrationEvent {
198                command, exit_code, ..
199            } => ScriptEventData::CommandComplete {
200                command: command.clone().unwrap_or_default(),
201                exit_code: *exit_code,
202            },
203
204            TerminalEvent::TriggerMatched(trigger_match) => ScriptEventData::TriggerMatched {
205                pattern: format!("trigger:{}", trigger_match.trigger_id),
206                matched_text: trigger_match.text.clone(),
207                line: trigger_match.row,
208            },
209
210            TerminalEvent::ZoneOpened {
211                zone_id, zone_type, ..
212            } => ScriptEventData::ZoneEvent {
213                zone_id: *zone_id as u64,
214                zone_type: zone_type.to_string(),
215                event: "opened".to_string(),
216            },
217
218            TerminalEvent::ZoneClosed {
219                zone_id, zone_type, ..
220            } => ScriptEventData::ZoneEvent {
221                zone_id: *zone_id as u64,
222                zone_type: zone_type.to_string(),
223                event: "closed".to_string(),
224            },
225
226            TerminalEvent::ZoneScrolledOut {
227                zone_id, zone_type, ..
228            } => ScriptEventData::ZoneEvent {
229                zone_id: *zone_id as u64,
230                zone_type: zone_type.to_string(),
231                event: "scrolled_out".to_string(),
232            },
233
234            // Fallback: capture arbitrary fields via Debug representation.
235            other => {
236                let mut fields = HashMap::new();
237                fields.insert(
238                    "debug".to_string(),
239                    serde_json::Value::String(format!("{:?}", other)),
240                );
241                ScriptEventData::Generic { fields }
242            }
243        };
244
245        ScriptEvent { kind, data }
246    }
247}
248
249// The core library's `TerminalEventKind` subscription filter is separate from
250// our string-based filter. We implement *both*:
251//  1. `subscriptions()` returns `None` so the core dispatches every event to us.
252//  2. `on_event()` applies our string-based filter before buffering.
253//
254// This keeps the filtering logic in one place (the string names match the
255// scripting protocol) while still being efficient — the core won't call us
256// for events we've filtered via `TerminalEventKind` if we chose to use that,
257// but since our filter is string-based we handle it ourselves.
258
259impl TerminalObserver for ScriptEventForwarder {
260    fn on_event(&self, event: &TerminalEvent) {
261        // Apply string-based subscription filter.
262        if let Some(ref filter) = self.subscription_filter {
263            let kind = Self::event_kind_name(event);
264            if !filter.contains(&kind) {
265                return;
266            }
267        }
268
269        let script_event = Self::convert_event(event);
270        let mut buf = self.event_buffer.lock().expect("event_buffer poisoned");
271
272        if buf.events.len() >= MAX_BUFFERED_EVENTS {
273            // Drop the oldest rather than the newest. A subscriber acts on
274            // what just happened, so discarding the incoming event would
275            // hide the very thing that is still worth reacting to and leave
276            // a stale prefix.
277            buf.events.pop_front();
278            if !buf.overflow_reported {
279                buf.overflow_reported = true;
280                // This forwarder backs both tab scripts and plugin
281                // subscriptions (`plugin_manager::supervision`), so the
282                // wording stays owner-neutral rather than naming "script"
283                // for what may be a plugin's process.
284                log::warn!(
285                    "Event buffer reached its {} event cap; discarding oldest \
286                     events from here on. Nothing is draining this forwarder — the \
287                     subscribing process most likely exited while its observer \
288                     stayed registered.",
289                    MAX_BUFFERED_EVENTS
290                );
291            }
292        }
293
294        buf.events.push_back(script_event);
295    }
296
297    // We do NOT override `subscriptions()` — returning `None` means
298    // "interested in all events" at the core level. Our own string filter
299    // is applied in `on_event` above.
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_event_kind_name_bell() {
308        let event =
309            TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
310        assert_eq!(ScriptEventForwarder::event_kind_name(&event), "bell_rang");
311    }
312
313    #[test]
314    fn test_event_kind_name_title() {
315        let event = TerminalEvent::TitleChanged("hello".to_string());
316        assert_eq!(
317            ScriptEventForwarder::event_kind_name(&event),
318            "title_changed"
319        );
320    }
321
322    #[test]
323    fn test_convert_bell_event() {
324        let event =
325            TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
326        let script_event = ScriptEventForwarder::convert_event(&event);
327        assert_eq!(script_event.kind, "bell_rang");
328        assert_eq!(script_event.data, ScriptEventData::Empty {});
329    }
330
331    #[test]
332    fn test_convert_title_event() {
333        let event = TerminalEvent::TitleChanged("My Title".to_string());
334        let script_event = ScriptEventForwarder::convert_event(&event);
335        assert_eq!(script_event.kind, "title_changed");
336        assert_eq!(
337            script_event.data,
338            ScriptEventData::TitleChanged {
339                title: "My Title".to_string(),
340            }
341        );
342    }
343
344    #[test]
345    fn test_convert_size_event() {
346        let event = TerminalEvent::SizeChanged(120, 40);
347        let script_event = ScriptEventForwarder::convert_event(&event);
348        assert_eq!(script_event.kind, "size_changed");
349        assert_eq!(
350            script_event.data,
351            ScriptEventData::SizeChanged {
352                cols: 120,
353                rows: 40,
354            }
355        );
356    }
357
358    #[test]
359    fn event_kinds_has_no_duplicates_and_names_representative_kinds() {
360        let mut sorted = EVENT_KINDS.to_vec();
361        sorted.sort_unstable();
362        let count = sorted.len();
363        sorted.dedup();
364        assert_eq!(sorted.len(), count, "EVENT_KINDS must not repeat a kind");
365        for kind in [
366            "bell_rang",
367            "command_complete",
368            "screen_cleared",
369            "file_transfer_failed",
370        ] {
371            assert!(
372                EVENT_KINDS.contains(&kind),
373                "{kind} missing from EVENT_KINDS"
374            );
375        }
376    }
377
378    #[test]
379    fn test_forwarder_no_filter_captures_all() {
380        let fwd = ScriptEventForwarder::new(None);
381        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
382        let title = TerminalEvent::TitleChanged("t".to_string());
383
384        fwd.on_event(&bell);
385        fwd.on_event(&title);
386
387        let events = fwd.drain_events();
388        assert_eq!(events.len(), 2);
389        assert_eq!(events[0].kind, "bell_rang");
390        assert_eq!(events[1].kind, "title_changed");
391    }
392
393    #[test]
394    fn test_forwarder_filters_by_subscription() {
395        let filter = HashSet::from(["bell_rang".to_string()]);
396        let fwd = ScriptEventForwarder::new(Some(filter));
397
398        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
399        let title = TerminalEvent::TitleChanged("t".to_string());
400
401        fwd.on_event(&bell);
402        fwd.on_event(&title);
403
404        let events = fwd.drain_events();
405        assert_eq!(events.len(), 1);
406        assert_eq!(events[0].kind, "bell_rang");
407    }
408
409    /// Extract the title from a `title_changed` event, so the drop-oldest tests
410    /// can identify which events survived.
411    fn title_of(event: &ScriptEvent) -> &str {
412        match &event.data {
413            ScriptEventData::TitleChanged { title } => title,
414            other => panic!("expected a title_changed event, got {other:?}"),
415        }
416    }
417
418    #[test]
419    fn buffer_stops_growing_at_the_cap() {
420        let fwd = ScriptEventForwarder::new(None);
421        for i in 0..(MAX_BUFFERED_EVENTS * 2) {
422            fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
423        }
424        assert_eq!(fwd.drain_events().len(), MAX_BUFFERED_EVENTS);
425    }
426
427    #[test]
428    fn overflow_drops_the_oldest_events_not_the_newest() {
429        let fwd = ScriptEventForwarder::new(None);
430        let overflow = 10;
431        for i in 0..(MAX_BUFFERED_EVENTS + overflow) {
432            fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
433        }
434
435        let events = fwd.drain_events();
436        assert_eq!(events.len(), MAX_BUFFERED_EVENTS);
437        // The first `overflow` events were discarded; the most recent survived.
438        assert_eq!(title_of(&events[0]), overflow.to_string());
439        assert_eq!(
440            title_of(&events[MAX_BUFFERED_EVENTS - 1]),
441            (MAX_BUFFERED_EVENTS + overflow - 1).to_string()
442        );
443    }
444
445    #[test]
446    fn draining_frees_the_capacity_again() {
447        let fwd = ScriptEventForwarder::new(None);
448        for i in 0..MAX_BUFFERED_EVENTS {
449            fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
450        }
451        assert_eq!(fwd.drain_events().len(), MAX_BUFFERED_EVENTS);
452
453        // A drained forwarder accepts a full buffer again without dropping.
454        fwd.on_event(&TerminalEvent::TitleChanged("after-drain".to_string()));
455        let events = fwd.drain_events();
456        assert_eq!(events.len(), 1);
457        assert_eq!(title_of(&events[0]), "after-drain");
458    }
459
460    #[test]
461    fn filtered_out_events_do_not_consume_buffer_capacity() {
462        let filter = HashSet::from(["bell_rang".to_string()]);
463        let fwd = ScriptEventForwarder::new(Some(filter));
464        for i in 0..(MAX_BUFFERED_EVENTS * 2) {
465            fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
466        }
467        assert!(fwd.drain_events().is_empty());
468    }
469
470    #[test]
471    fn test_drain_clears_buffer() {
472        let fwd = ScriptEventForwarder::new(None);
473        let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
474
475        fwd.on_event(&bell);
476        let events = fwd.drain_events();
477        assert_eq!(events.len(), 1);
478
479        // Second drain should be empty.
480        let events2 = fwd.drain_events();
481        assert!(events2.is_empty());
482    }
483}