Skip to main content

fresh_core/
hooks.rs

1//! Hook System: Event subscription and notification for plugins
2//!
3//! Hooks allow plugins to subscribe to editor events and react to them.
4
5use anyhow::Result;
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9use crate::action::Action;
10use crate::api::ViewTokenWire;
11use crate::{BufferId, CursorId, SplitId};
12
13/// Arguments passed to hook callbacks
14#[derive(Debug, Clone, serde::Serialize)]
15#[serde(untagged)]
16pub enum HookArgs {
17    /// Before a file is opened
18    BeforeFileOpen { path: PathBuf },
19
20    /// After a file is successfully opened
21    AfterFileOpen { buffer_id: BufferId, path: PathBuf },
22
23    /// Before a buffer is saved to disk
24    BeforeFileSave { buffer_id: BufferId, path: PathBuf },
25
26    /// After a buffer is successfully saved
27    AfterFileSave { buffer_id: BufferId, path: PathBuf },
28
29    /// The file explorer mutated the filesystem (paste, duplicate, ...)
30    /// without going through a buffer save. Plugins that surface
31    /// filesystem-derived state (git status decorations, etc.) use this
32    /// to re-scan after explorer-driven changes that wouldn't otherwise
33    /// fire `BeforeFileSave`/`AfterFileSave`. `path` is one of the
34    /// affected paths; for batch operations (multi-paste) the hook
35    /// fires once per refresh, not once per file.
36    AfterFileExplorerChange { path: PathBuf },
37
38    /// A buffer was closed
39    BufferClosed { buffer_id: BufferId },
40
41    /// Before text is inserted
42    BeforeInsert {
43        buffer_id: BufferId,
44        position: usize,
45        text: String,
46    },
47
48    /// After text was inserted
49    AfterInsert {
50        buffer_id: BufferId,
51        position: usize,
52        text: String,
53        /// Byte position where the affected range starts
54        affected_start: usize,
55        /// Byte position where the affected range ends (after the inserted text)
56        affected_end: usize,
57        /// Line number where insertion occurred (0-indexed)
58        start_line: usize,
59        /// Line number where insertion ended (0-indexed)
60        end_line: usize,
61        /// Number of lines added by this insertion
62        lines_added: usize,
63    },
64
65    /// Before text is deleted
66    BeforeDelete {
67        buffer_id: BufferId,
68        start: usize,
69        end: usize,
70    },
71
72    /// After text was deleted
73    AfterDelete {
74        buffer_id: BufferId,
75        start: usize,
76        end: usize,
77        deleted_text: String,
78        /// Byte position where the deletion occurred
79        affected_start: usize,
80        /// Length of the deleted content in bytes
81        deleted_len: usize,
82        /// Line number where deletion started (0-indexed)
83        start_line: usize,
84        /// Line number where deletion ended (0-indexed, in original buffer)
85        end_line: usize,
86        /// Number of lines removed by this deletion
87        lines_removed: usize,
88    },
89
90    /// Cursor moved to a new position
91    CursorMoved {
92        buffer_id: BufferId,
93        cursor_id: CursorId,
94        old_position: usize,
95        new_position: usize,
96        /// Line number at new position (1-indexed)
97        line: usize,
98        /// Text properties at the new cursor position
99        text_properties: Vec<std::collections::HashMap<String, serde_json::Value>>,
100    },
101
102    /// Buffer became active
103    BufferActivated { buffer_id: BufferId },
104
105    /// Buffer was deactivated
106    BufferDeactivated { buffer_id: BufferId },
107
108    /// LSP diagnostics were updated for a file
109    DiagnosticsUpdated {
110        /// The URI of the file that was updated
111        uri: String,
112        /// Number of diagnostics in the update
113        count: usize,
114    },
115
116    /// Before a command/action is executed
117    PreCommand { action: Action },
118
119    /// After a command/action was executed
120    PostCommand { action: Action },
121
122    /// Editor has been idle for N milliseconds (no input)
123    Idle { milliseconds: u64 },
124
125    /// Editor is initializing
126    EditorInitialized {},
127
128    /// All plugin packages + init.ts have been loaded. Fires after the
129    /// plugin discovery loop and before session restore — the lifecycle
130    /// hook for code that wants to configure a plugin via its
131    /// getPluginApi(...) surface. See design §3.3 (phase 2).
132    PluginsLoaded {},
133
134    /// Editor has completed startup: plugins are loaded, session is
135    /// restored, and the active buffer exists. Design §3.3 (phase 3).
136    Ready {},
137
138    /// The editor's active authority changed (e.g. local → container,
139    /// container → local). Fires after the new authority is in place
140    /// and the plugin state snapshot has been refreshed, so handlers
141    /// can read the new label via `editor.getAuthorityLabel()`.
142    /// Plugins use this to re-register state-dependent commands
143    /// that should only appear in one authority mode (e.g. dev
144    /// container `Detach` only when attached). In production a
145    /// transition triggers a full editor restart that re-runs plugin
146    /// init from scratch; this hook lets plugins react inline
147    /// without that, which keeps the harness in sync too.
148    AuthorityChanged { label: String },
149
150    /// Rendering is starting for a buffer (called once per buffer before render_line hooks)
151    RenderStart { buffer_id: BufferId },
152
153    /// A line is being rendered (called during the rendering pass)
154    RenderLine {
155        buffer_id: BufferId,
156        line_number: usize,
157        byte_start: usize,
158        byte_end: usize,
159        content: String,
160    },
161
162    /// Lines have changed and need processing (batched for efficiency)
163    LinesChanged {
164        buffer_id: BufferId,
165        lines: Vec<LineInfo>,
166    },
167
168    /// Prompt input changed (user typed/edited)
169    PromptChanged { prompt_type: String, input: String },
170
171    /// Prompt was confirmed (user pressed Enter)
172    PromptConfirmed {
173        prompt_type: String,
174        input: String,
175        selected_index: Option<usize>,
176    },
177
178    /// Prompt was cancelled (user pressed Escape/Ctrl+G)
179    PromptCancelled { prompt_type: String, input: String },
180
181    /// Prompt suggestion selection changed (user navigated with Up/Down)
182    PromptSelectionChanged {
183        prompt_type: String,
184        selected_index: usize,
185    },
186
187    /// Request keyboard shortcuts data (key, action) for the help buffer
188    KeyboardShortcuts { bindings: Vec<(String, String)> },
189
190    /// LSP find references response received
191    LspReferences {
192        /// The symbol name being queried
193        symbol: String,
194        /// The locations where the symbol is referenced
195        locations: Vec<LspLocation>,
196    },
197
198    /// View transform request
199    ViewTransformRequest {
200        buffer_id: BufferId,
201        split_id: SplitId,
202        /// Byte offset of the viewport start
203        viewport_start: usize,
204        /// Byte offset of the viewport end
205        viewport_end: usize,
206        /// Base tokens (Text, Newline, Space) from the source
207        tokens: Vec<ViewTokenWire>,
208        /// Byte positions of all cursors in this buffer
209        cursor_positions: Vec<usize>,
210    },
211
212    /// Mouse click event
213    MouseClick {
214        /// Column (x coordinate) in screen cells
215        column: u16,
216        /// Row (y coordinate) in screen cells
217        row: u16,
218        /// Mouse button: "left", "right", "middle"
219        button: String,
220        /// Modifier keys
221        modifiers: String,
222        /// Content area X offset
223        content_x: u16,
224        /// Content area Y offset
225        content_y: u16,
226        /// Buffer under the click (None when the click is outside any
227        /// buffer panel).
228        buffer_id: Option<u64>,
229        /// 0-indexed buffer row (line number) of the click, accounting
230        /// for scroll. None when the click is outside any buffer.
231        buffer_row: Option<u32>,
232        /// 0-indexed byte column inside the buffer row. None when the
233        /// click is outside any buffer.
234        buffer_col: Option<u32>,
235    },
236
237    /// Mouse move/hover event
238    MouseMove {
239        /// Column (x coordinate) in screen cells
240        column: u16,
241        /// Row (y coordinate) in screen cells
242        row: u16,
243        /// Content area X offset
244        content_x: u16,
245        /// Content area Y offset
246        content_y: u16,
247    },
248
249    /// LSP server request (server -> client)
250    LspServerRequest {
251        /// The language/server that sent the request
252        language: String,
253        /// The JSON-RPC method name
254        method: String,
255        /// The server command used to spawn this LSP
256        server_command: String,
257        /// The request parameters as a JSON string
258        params: Option<String>,
259    },
260
261    /// Viewport changed (scrolled or resized)
262    ViewportChanged {
263        split_id: SplitId,
264        buffer_id: BufferId,
265        top_byte: usize,
266        top_line: Option<usize>,
267        width: u16,
268        height: u16,
269    },
270
271    /// LSP server failed to start or crashed
272    LspServerError {
273        /// The language that failed
274        language: String,
275        /// The server command that failed
276        server_command: String,
277        /// Error type: "not_found", "spawn_failed", "timeout", "crash"
278        error_type: String,
279        /// Human-readable error message
280        message: String,
281    },
282
283    /// User clicked the LSP status indicator
284    LspStatusClicked {
285        /// The language of the current buffer
286        language: String,
287        /// Whether there's an active error
288        has_error: bool,
289        /// Commands of configured servers whose binaries are not on `$PATH`
290        /// (or absolute-path equivalents). Empty when every configured
291        /// server is installed. Plugins can inspect this to show tailored
292        /// install hints without waiting for a failed spawn.
293        missing_servers: Vec<String>,
294        /// Whether the user previously dismissed the LSP pill for this
295        /// language (via the popup's "Disable" action). Plugins seeing
296        /// this as `true` should offer "Enable" / "Install" rather than
297        /// "Start".
298        user_dismissed: bool,
299    },
300
301    /// User selected an action from an action popup
302    ActionPopupResult {
303        /// The popup ID
304        popup_id: String,
305        /// The action ID selected, or "dismissed"
306        action_id: String,
307    },
308
309    /// User clicked a plugin-registered status-bar token. Fires
310    /// regardless of which plugin registered it; subscribers filter
311    /// by `plugin_name` + `token_name`. Plugins typically use this
312    /// to re-open a deferred prompt or surface the relevant
313    /// settings UI for what the token represents (e.g., trust chip
314    /// click → trust elevation popup, env pill click → env activate
315    /// popup).
316    StatusBarTokenClicked {
317        /// Plugin that originally called `RegisterStatusBarElement`.
318        plugin_name: String,
319        /// Token name within that plugin's namespace.
320        token_name: String,
321    },
322
323    /// Background process output (streaming)
324    ProcessOutput {
325        /// The process ID
326        process_id: u64,
327        /// The output data
328        data: String,
329    },
330
331    /// A new editor session was created. Fires after the session is
332    /// added to `Editor.sessions`, before any UI retarget. Plugins
333    /// (like Orchestrator) use this to reconcile their per-session
334    /// bookkeeping with the editor.
335    WindowCreated {
336        /// The new session's stable id.
337        id: u64,
338        /// Resolved label (basename fallback applied).
339        label: String,
340        /// Absolute project root.
341        root: String,
342    },
343
344    /// An editor session was closed and its state dropped. The id
345    /// is still valid in the payload but is no longer present in
346    /// `editor.listWindows()`.
347    WindowClosed { id: u64 },
348
349    /// The active session changed. Fires after the editor's UI has
350    /// retargeted (file tree, working_dir, snapshot). Plugins
351    /// observing for "the editor's project root just changed" use
352    /// this rather than polling.
353    ActiveWindowChanged {
354        /// The previously active session id, or `None` only on
355        /// first switch from the initial base session — currently
356        /// always `Some` since the base session always exists.
357        previous_id: Option<u64>,
358        /// The newly active session id. Always present in the
359        /// `sessions` list.
360        active_id: u64,
361    },
362
363    /// PTY terminal received output bytes from the spawned process.
364    /// Fires for every async batch the editor reads off the PTY, so it
365    /// is hot — consumers should be cheap. The payload includes only a
366    /// snapshot of the last visible (cursor) row so plugins can detect
367    /// prompt patterns (`(Y/n)`, `Press enter`, `> `) without an extra
368    /// readback API. Plugins that need full output should tail the
369    /// terminal's backing file via the existing buffer.
370    TerminalOutput {
371        /// Stable terminal session id (matches `TerminalId.0`).
372        terminal_id: u64,
373        /// Editor window that owns this terminal (matches `WindowId.0`).
374        /// Lets a plugin attribute output to a *session* — Orchestrator
375        /// keys activity off the window, so output from ANY terminal in
376        /// the window (not just the one the plugin spawned) counts. Fires
377        /// on every PTY read, so in-place redraws and carriage-return
378        /// progress bars register as activity too, not just newlines.
379        window_id: u64,
380        /// Snapshot of the cursor row's text content. May be empty
381        /// (just-resized terminal, cleared screen). Trailing whitespace
382        /// is preserved because prompt detection often depends on it
383        /// (e.g. `"... (Y/n): "` ends in a space).
384        last_line: String,
385    },
386
387    /// PTY terminal's spawned process has ended. Fires once per
388    /// terminal lifetime, after the editor has flushed any final
389    /// scrollback to the backing file.
390    TerminalExited {
391        /// Stable terminal session id (matches `TerminalId.0`).
392        terminal_id: u64,
393        /// Editor window that owned this terminal (matches `WindowId.0`).
394        window_id: u64,
395        /// Process exit code if known. `None` when the platform did
396        /// not report a status (signal, detach, kill before wait).
397        /// Plugins that can't distinguish should treat `None` as
398        /// "errored, cause unknown" rather than "ready".
399        exit_code: Option<i32>,
400    },
401
402    /// A path under a `watchPath`-registered watcher changed.
403    /// Plugins (Orchestrator's collision radar, etc.) use this to
404    /// build path → modifying-session-set matrices. Fires once per
405    /// raw `notify` event — no debouncing in core; plugins coalesce
406    /// per their policy.
407    PathChanged {
408        /// Watch handle that delivered this event. Maps back to
409        /// the `watchPath()` call that registered it; lets plugins
410        /// route events to per-watcher state.
411        handle: u64,
412        /// Absolute path the kernel reported as changed.
413        path: String,
414        /// `"modify"` | `"create"` | `"delete"` | `"rename"` |
415        /// `"other"`. Conservative bucketing of `notify::EventKind`
416        /// — plugins that need finer detail can switch on more
417        /// specific strings the editor learns to emit later.
418        kind: String,
419    },
420
421    /// Buffer language was changed (e.g. via "Set Language" command or Save-As)
422    LanguageChanged {
423        buffer_id: BufferId,
424        /// The new language identifier (e.g., "markdown", "rust", "text")
425        language: String,
426    },
427
428    /// Request to inspect a theme key in the theme editor
429    ThemeInspectKey {
430        /// The name of the current theme
431        theme_name: String,
432        /// The theme key to inspect (e.g. "editor.bg")
433        key: String,
434    },
435
436    /// Mouse scroll event (wheel up/down)
437    MouseScroll {
438        buffer_id: BufferId,
439        /// Scroll delta: negative = up, positive = down (typically ±3)
440        delta: i32,
441        /// Mouse column (0-based, terminal origin top-left)
442        col: u16,
443        /// Mouse row (0-based, terminal origin top-left)
444        row: u16,
445    },
446
447    /// Terminal was resized
448    Resize { width: u16, height: u16 },
449
450    /// Terminal focus was gained (e.g. user switched back to the editor)
451    FocusGained {},
452
453    /// A widget mounted via `MountWidgetPanel` emitted a semantic event.
454    /// Plugins subscribe via `editor.on("widget_event", "<handler>")`
455    /// and dispatch on `(panel_id, widget_key, event_type)`.
456    ///
457    /// `event_type` is one of: `"activate"`, `"toggle"`, `"change"`,
458    /// `"submit"`, `"hover"`, `"dismiss"`, `"focus"`. `payload` is
459    /// event-specific JSON (e.g. `{ "value": "search text" }` for
460    /// `change`, `{ "previous": "<old key>" }` for `focus`).
461    ///
462    /// At v1 only widgets that have user-driven behaviour fire this
463    /// hook. The HintBar widget is read-only and does not emit events.
464    WidgetEvent {
465        /// The plugin-allocated panel ID from the original
466        /// `MountWidgetPanel`.
467        panel_id: u64,
468        /// The stable `key` of the widget node that fired the event,
469        /// or empty when the event originates from the panel root.
470        widget_key: String,
471        /// The kind of event — see variants above.
472        event_type: String,
473        /// Event-specific JSON payload.
474        #[serde(default)]
475        payload: serde_json::Value,
476    },
477}
478
479/// Information about a single line for the LinesChanged hook
480#[derive(Debug, Clone, serde::Serialize)]
481pub struct LineInfo {
482    /// Line number (0-based)
483    pub line_number: usize,
484    /// Byte offset where the line starts in the buffer
485    pub byte_start: usize,
486    /// Byte offset where the line ends (exclusive)
487    pub byte_end: usize,
488    /// The content of the line
489    pub content: String,
490}
491
492/// Location information for LSP references
493#[derive(Debug, Clone, serde::Serialize)]
494pub struct LspLocation {
495    /// File path
496    pub file: String,
497    /// Line number (1-based)
498    pub line: u32,
499    /// Column number (1-based)
500    pub column: u32,
501}
502
503/// Type for hook callbacks
504pub type HookCallback = Box<dyn Fn(&HookArgs) -> bool + Send + Sync>;
505
506/// Registry for managing hooks
507pub struct HookRegistry {
508    /// Map from hook name to list of callbacks
509    hooks: HashMap<String, Vec<HookCallback>>,
510}
511
512impl HookRegistry {
513    /// Create a new hook registry
514    pub fn new() -> Self {
515        Self {
516            hooks: HashMap::new(),
517        }
518    }
519
520    /// Add a hook callback for a specific hook name
521    pub fn add_hook(&mut self, name: &str, callback: HookCallback) {
522        self.hooks
523            .entry(name.to_string())
524            .or_default()
525            .push(callback);
526    }
527
528    /// Remove all hooks for a specific name
529    pub fn remove_hooks(&mut self, name: &str) {
530        self.hooks.remove(name);
531    }
532
533    /// Run all hooks for a specific name
534    pub fn run_hooks(&self, name: &str, args: &HookArgs) -> bool {
535        if let Some(hooks) = self.hooks.get(name) {
536            for callback in hooks {
537                if !callback(args) {
538                    return false;
539                }
540            }
541        }
542        true
543    }
544
545    /// Get count of registered callbacks for a hook
546    pub fn hook_count(&self, name: &str) -> usize {
547        self.hooks.get(name).map(|v| v.len()).unwrap_or(0)
548    }
549
550    /// Get all registered hook names
551    pub fn hook_names(&self) -> Vec<String> {
552        self.hooks.keys().cloned().collect()
553    }
554}
555
556impl Default for HookRegistry {
557    fn default() -> Self {
558        Self::new()
559    }
560}
561
562/// Convert HookArgs to a serde_json::Value for plugin communication.
563///
564/// `HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
565/// fields only — no discriminant wrapper. Empty struct variants (`{}`) produce
566/// an empty JSON object rather than `null`.
567pub fn hook_args_to_json(args: &HookArgs) -> Result<serde_json::Value> {
568    Ok(serde_json::to_value(args)?)
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use std::sync::atomic::{AtomicUsize, Ordering};
575    use std::sync::Arc;
576
577    fn noop_true() -> HookCallback {
578        Box::new(|_| true)
579    }
580
581    /// Adding, listing, counting, and removing hooks behave consistently:
582    /// counts match the number added, names reflect the keys, and removal
583    /// purges all callbacks for that key.
584    #[test]
585    fn add_count_list_remove_round_trip() {
586        let mut reg = HookRegistry::new();
587        assert_eq!(reg.hook_count("a"), 0);
588        assert!(reg.hook_names().is_empty());
589
590        reg.add_hook("a", noop_true());
591        reg.add_hook("a", noop_true());
592        reg.add_hook("b", noop_true());
593
594        assert_eq!(reg.hook_count("a"), 2);
595        assert_eq!(reg.hook_count("b"), 1);
596        assert_eq!(reg.hook_count("missing"), 0);
597
598        let mut names = reg.hook_names();
599        names.sort();
600        assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
601
602        reg.remove_hooks("a");
603        assert_eq!(reg.hook_count("a"), 0);
604        assert_eq!(reg.hook_count("b"), 1);
605        assert_eq!(reg.hook_names(), vec!["b".to_string()]);
606    }
607
608    /// `run_hooks` returns true iff every callback returned true, short-circuits
609    /// on the first `false`, and returns true for hook names with no callbacks.
610    #[test]
611    fn run_hooks_all_true_and_short_circuits_on_false() {
612        let mut reg = HookRegistry::new();
613        let args = HookArgs::EditorInitialized {};
614
615        // Unknown hook: treated as "no callbacks" → true.
616        assert!(reg.run_hooks("unknown", &args));
617
618        // All-true chain returns true and calls every callback.
619        let calls = Arc::new(AtomicUsize::new(0));
620        for _ in 0..3 {
621            let c = calls.clone();
622            reg.add_hook(
623                "all_true",
624                Box::new(move |_| {
625                    c.fetch_add(1, Ordering::SeqCst);
626                    true
627                }),
628            );
629        }
630        assert!(reg.run_hooks("all_true", &args));
631        assert_eq!(calls.load(Ordering::SeqCst), 3);
632
633        // Short-circuits on the first `false` — the second callback must not run.
634        let calls = Arc::new(AtomicUsize::new(0));
635        let c1 = calls.clone();
636        reg.add_hook(
637            "short",
638            Box::new(move |_| {
639                c1.fetch_add(1, Ordering::SeqCst);
640                false
641            }),
642        );
643        let c2 = calls.clone();
644        reg.add_hook(
645            "short",
646            Box::new(move |_| {
647                c2.fetch_add(1, Ordering::SeqCst);
648                true
649            }),
650        );
651        assert!(!reg.run_hooks("short", &args));
652        assert_eq!(calls.load(Ordering::SeqCst), 1);
653    }
654
655    /// `hook_args_to_json` produces an object with the expected field for
656    /// a representative variant — ensuring the function actually serializes
657    /// the payload instead of returning a default (null) value.
658    #[test]
659    fn hook_args_to_json_serializes_payload_fields() {
660        let json = hook_args_to_json(&HookArgs::DiagnosticsUpdated {
661            uri: "file:///x.rs".into(),
662            count: 7,
663        })
664        .unwrap();
665        assert_eq!(json["uri"], "file:///x.rs");
666        assert_eq!(json["count"], 7);
667    }
668
669    #[test]
670    fn hook_args_to_json_empty_variants_produce_empty_object() {
671        for args in [
672            HookArgs::EditorInitialized {},
673            HookArgs::PluginsLoaded {},
674            HookArgs::Ready {},
675            HookArgs::FocusGained {},
676        ] {
677            let json = hook_args_to_json(&args).unwrap();
678            assert_eq!(
679                json,
680                serde_json::json!({}),
681                "variant should serialize as {{}}"
682            );
683        }
684    }
685
686    #[test]
687    fn hook_args_to_json_terminal_output_fields_are_flat() {
688        let json = hook_args_to_json(&HookArgs::TerminalOutput {
689            terminal_id: 7,
690            window_id: 2,
691            last_line: "Do you want me to attempt a fix? (Y/n): ".into(),
692        })
693        .unwrap();
694        assert_eq!(json["terminal_id"], 7);
695        assert_eq!(json["window_id"], 2);
696        assert_eq!(
697            json["last_line"],
698            "Do you want me to attempt a fix? (Y/n): "
699        );
700    }
701
702    #[test]
703    fn hook_args_to_json_terminal_exited_serializes_exit_code() {
704        let json_some = hook_args_to_json(&HookArgs::TerminalExited {
705            terminal_id: 3,
706            window_id: 1,
707            exit_code: Some(0),
708        })
709        .unwrap();
710        assert_eq!(json_some["terminal_id"], 3);
711        assert_eq!(json_some["window_id"], 1);
712        assert_eq!(json_some["exit_code"], 0);
713
714        let json_err = hook_args_to_json(&HookArgs::TerminalExited {
715            terminal_id: 4,
716            window_id: 1,
717            exit_code: Some(2),
718        })
719        .unwrap();
720        assert_eq!(json_err["exit_code"], 2);
721
722        let json_none = hook_args_to_json(&HookArgs::TerminalExited {
723            terminal_id: 5,
724            window_id: 1,
725            exit_code: None,
726        })
727        .unwrap();
728        assert!(
729            json_none["exit_code"].is_null(),
730            "exit_code: None should serialize as JSON null, not omitted: got {json_none}"
731        );
732    }
733
734    #[test]
735    fn hook_args_to_json_delete_fields_are_flat() {
736        let json = hook_args_to_json(&HookArgs::BeforeDelete {
737            buffer_id: crate::BufferId(1),
738            start: 10,
739            end: 20,
740        })
741        .unwrap();
742        assert_eq!(json["start"], 10);
743        assert_eq!(json["end"], 20);
744        assert!(json.get("range").is_none(), "range must not be nested");
745    }
746}