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}