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::ops::Range;
8use std::path::PathBuf;
9
10use crate::action::Action;
11use crate::api::{ViewTokenWire, ViewTokenWireKind};
12use crate::{BufferId, CursorId, SplitId};
13
14/// Arguments passed to hook callbacks
15#[derive(Debug, Clone, serde::Serialize)]
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    /// A buffer was closed
30    BufferClosed { buffer_id: BufferId },
31
32    /// Before text is inserted
33    BeforeInsert {
34        buffer_id: BufferId,
35        position: usize,
36        text: String,
37    },
38
39    /// After text was inserted
40    AfterInsert {
41        buffer_id: BufferId,
42        position: usize,
43        text: String,
44        /// Byte position where the affected range starts
45        affected_start: usize,
46        /// Byte position where the affected range ends (after the inserted text)
47        affected_end: usize,
48        /// Line number where insertion occurred (0-indexed)
49        start_line: usize,
50        /// Line number where insertion ended (0-indexed)
51        end_line: usize,
52        /// Number of lines added by this insertion
53        lines_added: usize,
54    },
55
56    /// Before text is deleted
57    BeforeDelete {
58        buffer_id: BufferId,
59        range: Range<usize>,
60    },
61
62    /// After text was deleted
63    AfterDelete {
64        buffer_id: BufferId,
65        range: Range<usize>,
66        deleted_text: String,
67        /// Byte position where the deletion occurred
68        affected_start: usize,
69        /// Length of the deleted content in bytes
70        deleted_len: usize,
71        /// Line number where deletion started (0-indexed)
72        start_line: usize,
73        /// Line number where deletion ended (0-indexed, in original buffer)
74        end_line: usize,
75        /// Number of lines removed by this deletion
76        lines_removed: usize,
77    },
78
79    /// Cursor moved to a new position
80    CursorMoved {
81        buffer_id: BufferId,
82        cursor_id: CursorId,
83        old_position: usize,
84        new_position: usize,
85        /// Line number at new position (1-indexed)
86        line: usize,
87        /// Text properties at the new cursor position
88        text_properties: Vec<std::collections::HashMap<String, serde_json::Value>>,
89    },
90
91    /// Buffer became active
92    BufferActivated { buffer_id: BufferId },
93
94    /// Buffer was deactivated
95    BufferDeactivated { buffer_id: BufferId },
96
97    /// LSP diagnostics were updated for a file
98    DiagnosticsUpdated {
99        /// The URI of the file that was updated
100        uri: String,
101        /// Number of diagnostics in the update
102        count: usize,
103    },
104
105    /// Before a command/action is executed
106    PreCommand { action: Action },
107
108    /// After a command/action was executed
109    PostCommand { action: Action },
110
111    /// Editor has been idle for N milliseconds (no input)
112    Idle { milliseconds: u64 },
113
114    /// Editor is initializing
115    EditorInitialized,
116
117    /// Rendering is starting for a buffer (called once per buffer before render_line hooks)
118    RenderStart { buffer_id: BufferId },
119
120    /// A line is being rendered (called during the rendering pass)
121    RenderLine {
122        buffer_id: BufferId,
123        line_number: usize,
124        byte_start: usize,
125        byte_end: usize,
126        content: String,
127    },
128
129    /// Lines have changed and need processing (batched for efficiency)
130    LinesChanged {
131        buffer_id: BufferId,
132        lines: Vec<LineInfo>,
133    },
134
135    /// Prompt input changed (user typed/edited)
136    PromptChanged { prompt_type: String, input: String },
137
138    /// Prompt was confirmed (user pressed Enter)
139    PromptConfirmed {
140        prompt_type: String,
141        input: String,
142        selected_index: Option<usize>,
143    },
144
145    /// Prompt was cancelled (user pressed Escape/Ctrl+G)
146    PromptCancelled { prompt_type: String, input: String },
147
148    /// Prompt suggestion selection changed (user navigated with Up/Down)
149    PromptSelectionChanged {
150        prompt_type: String,
151        selected_index: usize,
152    },
153
154    /// Request keyboard shortcuts data (key, action) for the help buffer
155    KeyboardShortcuts { bindings: Vec<(String, String)> },
156
157    /// LSP find references response received
158    LspReferences {
159        /// The symbol name being queried
160        symbol: String,
161        /// The locations where the symbol is referenced
162        locations: Vec<LspLocation>,
163    },
164
165    /// View transform request
166    ViewTransformRequest {
167        buffer_id: BufferId,
168        split_id: SplitId,
169        /// Byte offset of the viewport start
170        viewport_start: usize,
171        /// Byte offset of the viewport end
172        viewport_end: usize,
173        /// Base tokens (Text, Newline, Space) from the source
174        tokens: Vec<ViewTokenWire>,
175        /// Byte positions of all cursors in this buffer
176        cursor_positions: Vec<usize>,
177    },
178
179    /// Mouse click event
180    MouseClick {
181        /// Column (x coordinate) in screen cells
182        column: u16,
183        /// Row (y coordinate) in screen cells
184        row: u16,
185        /// Mouse button: "left", "right", "middle"
186        button: String,
187        /// Modifier keys
188        modifiers: String,
189        /// Content area X offset
190        content_x: u16,
191        /// Content area Y offset
192        content_y: u16,
193        /// Buffer under the click (None when the click is outside any
194        /// buffer panel).
195        buffer_id: Option<u64>,
196        /// 0-indexed buffer row (line number) of the click, accounting
197        /// for scroll. None when the click is outside any buffer.
198        buffer_row: Option<u32>,
199        /// 0-indexed byte column inside the buffer row. None when the
200        /// click is outside any buffer.
201        buffer_col: Option<u32>,
202    },
203
204    /// Mouse move/hover event
205    MouseMove {
206        /// Column (x coordinate) in screen cells
207        column: u16,
208        /// Row (y coordinate) in screen cells
209        row: u16,
210        /// Content area X offset
211        content_x: u16,
212        /// Content area Y offset
213        content_y: u16,
214    },
215
216    /// LSP server request (server -> client)
217    LspServerRequest {
218        /// The language/server that sent the request
219        language: String,
220        /// The JSON-RPC method name
221        method: String,
222        /// The server command used to spawn this LSP
223        server_command: String,
224        /// The request parameters as a JSON string
225        params: Option<String>,
226    },
227
228    /// Viewport changed (scrolled or resized)
229    ViewportChanged {
230        split_id: SplitId,
231        buffer_id: BufferId,
232        top_byte: usize,
233        top_line: Option<usize>,
234        width: u16,
235        height: u16,
236    },
237
238    /// LSP server failed to start or crashed
239    LspServerError {
240        /// The language that failed
241        language: String,
242        /// The server command that failed
243        server_command: String,
244        /// Error type: "not_found", "spawn_failed", "timeout", "crash"
245        error_type: String,
246        /// Human-readable error message
247        message: String,
248    },
249
250    /// User clicked the LSP status indicator
251    LspStatusClicked {
252        /// The language of the current buffer
253        language: String,
254        /// Whether there's an active error
255        has_error: bool,
256        /// Commands of configured servers whose binaries are not on `$PATH`
257        /// (or absolute-path equivalents). Empty when every configured
258        /// server is installed. Plugins can inspect this to show tailored
259        /// install hints without waiting for a failed spawn.
260        missing_servers: Vec<String>,
261        /// Whether the user previously dismissed the LSP pill for this
262        /// language (via the popup's "Disable" action). Plugins seeing
263        /// this as `true` should offer "Enable" / "Install" rather than
264        /// "Start".
265        user_dismissed: bool,
266    },
267
268    /// User selected an action from an action popup
269    ActionPopupResult {
270        /// The popup ID
271        popup_id: String,
272        /// The action ID selected, or "dismissed"
273        action_id: String,
274    },
275
276    /// Background process output (streaming)
277    ProcessOutput {
278        /// The process ID
279        process_id: u64,
280        /// The output data
281        data: String,
282    },
283
284    /// Buffer language was changed (e.g. via "Set Language" command or Save-As)
285    LanguageChanged {
286        buffer_id: BufferId,
287        /// The new language identifier (e.g., "markdown", "rust", "text")
288        language: String,
289    },
290
291    /// Request to inspect a theme key in the theme editor
292    ThemeInspectKey {
293        /// The name of the current theme
294        theme_name: String,
295        /// The theme key to inspect (e.g. "editor.bg")
296        key: String,
297    },
298
299    /// Mouse scroll event (wheel up/down)
300    MouseScroll {
301        buffer_id: BufferId,
302        /// Scroll delta: negative = up, positive = down (typically ±3)
303        delta: i32,
304        /// Mouse column (0-based, terminal origin top-left)
305        col: u16,
306        /// Mouse row (0-based, terminal origin top-left)
307        row: u16,
308    },
309
310    /// Terminal was resized
311    Resize { width: u16, height: u16 },
312
313    /// Terminal focus was gained (e.g. user switched back to the editor)
314    FocusGained,
315}
316
317/// Information about a single line for the LinesChanged hook
318#[derive(Debug, Clone, serde::Serialize)]
319pub struct LineInfo {
320    /// Line number (0-based)
321    pub line_number: usize,
322    /// Byte offset where the line starts in the buffer
323    pub byte_start: usize,
324    /// Byte offset where the line ends (exclusive)
325    pub byte_end: usize,
326    /// The content of the line
327    pub content: String,
328}
329
330/// Location information for LSP references
331#[derive(Debug, Clone, serde::Serialize)]
332pub struct LspLocation {
333    /// File path
334    pub file: String,
335    /// Line number (1-based)
336    pub line: u32,
337    /// Column number (1-based)
338    pub column: u32,
339}
340
341/// Type for hook callbacks
342pub type HookCallback = Box<dyn Fn(&HookArgs) -> bool + Send + Sync>;
343
344/// Registry for managing hooks
345pub struct HookRegistry {
346    /// Map from hook name to list of callbacks
347    hooks: HashMap<String, Vec<HookCallback>>,
348}
349
350impl HookRegistry {
351    /// Create a new hook registry
352    pub fn new() -> Self {
353        Self {
354            hooks: HashMap::new(),
355        }
356    }
357
358    /// Add a hook callback for a specific hook name
359    pub fn add_hook(&mut self, name: &str, callback: HookCallback) {
360        self.hooks
361            .entry(name.to_string())
362            .or_default()
363            .push(callback);
364    }
365
366    /// Remove all hooks for a specific name
367    pub fn remove_hooks(&mut self, name: &str) {
368        self.hooks.remove(name);
369    }
370
371    /// Run all hooks for a specific name
372    pub fn run_hooks(&self, name: &str, args: &HookArgs) -> bool {
373        if let Some(hooks) = self.hooks.get(name) {
374            for callback in hooks {
375                if !callback(args) {
376                    return false;
377                }
378            }
379        }
380        true
381    }
382
383    /// Get count of registered callbacks for a hook
384    pub fn hook_count(&self, name: &str) -> usize {
385        self.hooks.get(name).map(|v| v.len()).unwrap_or(0)
386    }
387
388    /// Get all registered hook names
389    pub fn hook_names(&self) -> Vec<String> {
390        self.hooks.keys().cloned().collect()
391    }
392}
393
394impl Default for HookRegistry {
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400/// Convert HookArgs to a serde_json::Value for plugin communication
401pub fn hook_args_to_json(args: &HookArgs) -> Result<serde_json::Value> {
402    let json_value = match args {
403        HookArgs::RenderStart { buffer_id } => {
404            serde_json::json!({
405                "buffer_id": buffer_id.0,
406            })
407        }
408        HookArgs::RenderLine {
409            buffer_id,
410            line_number,
411            byte_start,
412            byte_end,
413            content,
414        } => {
415            serde_json::json!({
416                "buffer_id": buffer_id.0,
417                "line_number": line_number,
418                "byte_start": byte_start,
419                "byte_end": byte_end,
420                "content": content,
421            })
422        }
423        HookArgs::BufferActivated { buffer_id } => {
424            serde_json::json!({ "buffer_id": buffer_id.0 })
425        }
426        HookArgs::BufferDeactivated { buffer_id } => {
427            serde_json::json!({ "buffer_id": buffer_id.0 })
428        }
429        HookArgs::DiagnosticsUpdated { uri, count } => {
430            serde_json::json!({
431                "uri": uri,
432                "count": count,
433            })
434        }
435        HookArgs::BufferClosed { buffer_id } => {
436            serde_json::json!({ "buffer_id": buffer_id.0 })
437        }
438        HookArgs::CursorMoved {
439            buffer_id,
440            cursor_id,
441            old_position,
442            new_position,
443            line,
444            text_properties,
445        } => {
446            serde_json::json!({
447                "buffer_id": buffer_id.0,
448                "cursor_id": cursor_id.0,
449                "old_position": old_position,
450                "new_position": new_position,
451                "line": line,
452                "text_properties": text_properties,
453            })
454        }
455        HookArgs::BeforeInsert {
456            buffer_id,
457            position,
458            text,
459        } => {
460            serde_json::json!({
461                "buffer_id": buffer_id.0,
462                "position": position,
463                "text": text,
464            })
465        }
466        HookArgs::AfterInsert {
467            buffer_id,
468            position,
469            text,
470            affected_start,
471            affected_end,
472            start_line,
473            end_line,
474            lines_added,
475        } => {
476            serde_json::json!({
477                "buffer_id": buffer_id.0,
478                "position": position,
479                "text": text,
480                "affected_start": affected_start,
481                "affected_end": affected_end,
482                "start_line": start_line,
483                "end_line": end_line,
484                "lines_added": lines_added,
485            })
486        }
487        HookArgs::BeforeDelete { buffer_id, range } => {
488            serde_json::json!({
489                "buffer_id": buffer_id.0,
490                "start": range.start,
491                "end": range.end,
492            })
493        }
494        HookArgs::AfterDelete {
495            buffer_id,
496            range,
497            deleted_text,
498            affected_start,
499            deleted_len,
500            start_line,
501            end_line,
502            lines_removed,
503        } => {
504            serde_json::json!({
505                "buffer_id": buffer_id.0,
506                "start": range.start,
507                "end": range.end,
508                "deleted_text": deleted_text,
509                "affected_start": affected_start,
510                "deleted_len": deleted_len,
511                "start_line": start_line,
512                "end_line": end_line,
513                "lines_removed": lines_removed,
514            })
515        }
516        HookArgs::BeforeFileOpen { path } => {
517            serde_json::json!({ "path": path.to_string_lossy() })
518        }
519        HookArgs::AfterFileOpen { path, buffer_id } => {
520            serde_json::json!({
521                "path": path.to_string_lossy(),
522                "buffer_id": buffer_id.0,
523            })
524        }
525        HookArgs::BeforeFileSave { path, buffer_id } => {
526            serde_json::json!({
527                "path": path.to_string_lossy(),
528                "buffer_id": buffer_id.0,
529            })
530        }
531        HookArgs::AfterFileSave { path, buffer_id } => {
532            serde_json::json!({
533                "path": path.to_string_lossy(),
534                "buffer_id": buffer_id.0,
535            })
536        }
537        HookArgs::PreCommand { action } => {
538            serde_json::json!({ "action": format!("{:?}", action) })
539        }
540        HookArgs::PostCommand { action } => {
541            serde_json::json!({ "action": format!("{:?}", action) })
542        }
543        HookArgs::Idle { milliseconds } => {
544            serde_json::json!({ "milliseconds": milliseconds })
545        }
546        HookArgs::EditorInitialized => {
547            serde_json::json!({})
548        }
549        HookArgs::PromptChanged { prompt_type, input } => {
550            serde_json::json!({
551                "prompt_type": prompt_type,
552                "input": input,
553            })
554        }
555        HookArgs::PromptConfirmed {
556            prompt_type,
557            input,
558            selected_index,
559        } => {
560            serde_json::json!({
561                "prompt_type": prompt_type,
562                "input": input,
563                "selected_index": selected_index,
564            })
565        }
566        HookArgs::PromptCancelled { prompt_type, input } => {
567            serde_json::json!({
568                "prompt_type": prompt_type,
569                "input": input,
570            })
571        }
572        HookArgs::PromptSelectionChanged {
573            prompt_type,
574            selected_index,
575        } => {
576            serde_json::json!({
577                "prompt_type": prompt_type,
578                "selected_index": selected_index,
579            })
580        }
581        HookArgs::KeyboardShortcuts { bindings } => {
582            let entries: Vec<serde_json::Value> = bindings
583                .iter()
584                .map(|(key, action)| serde_json::json!({ "key": key, "action": action }))
585                .collect();
586            serde_json::json!({ "bindings": entries })
587        }
588        HookArgs::LspReferences { symbol, locations } => {
589            let locs: Vec<serde_json::Value> = locations
590                .iter()
591                .map(|loc| {
592                    serde_json::json!({
593                        "file": loc.file,
594                        "line": loc.line,
595                        "column": loc.column,
596                    })
597                })
598                .collect();
599            serde_json::json!({ "symbol": symbol, "locations": locs })
600        }
601        HookArgs::LinesChanged { buffer_id, lines } => {
602            let lines_json: Vec<serde_json::Value> = lines
603                .iter()
604                .map(|line| {
605                    serde_json::json!({
606                        "line_number": line.line_number,
607                        "byte_start": line.byte_start,
608                        "byte_end": line.byte_end,
609                        "content": line.content,
610                    })
611                })
612                .collect();
613            serde_json::json!({
614                "buffer_id": buffer_id.0,
615                "lines": lines_json,
616            })
617        }
618        HookArgs::ViewTransformRequest {
619            buffer_id,
620            split_id,
621            viewport_start,
622            viewport_end,
623            tokens,
624            cursor_positions,
625        } => {
626            let tokens_json: Vec<serde_json::Value> = tokens
627                .iter()
628                .map(|token| {
629                    let kind_json = match &token.kind {
630                        ViewTokenWireKind::Text(s) => serde_json::json!({ "Text": s }),
631                        ViewTokenWireKind::Newline => serde_json::json!("Newline"),
632                        ViewTokenWireKind::Space => serde_json::json!("Space"),
633                        ViewTokenWireKind::Break => serde_json::json!("Break"),
634                        ViewTokenWireKind::BinaryByte(b) => serde_json::json!({ "BinaryByte": b }),
635                    };
636                    serde_json::json!({
637                        "source_offset": token.source_offset,
638                        "kind": kind_json,
639                    })
640                })
641                .collect();
642            serde_json::json!({
643                "buffer_id": buffer_id.0,
644                "split_id": split_id.0,
645                "viewport_start": viewport_start,
646                "viewport_end": viewport_end,
647                "tokens": tokens_json,
648                "cursor_positions": cursor_positions,
649            })
650        }
651        HookArgs::MouseClick {
652            column,
653            row,
654            button,
655            modifiers,
656            content_x,
657            content_y,
658            buffer_id,
659            buffer_row,
660            buffer_col,
661        } => {
662            serde_json::json!({
663                "column": column,
664                "row": row,
665                "button": button,
666                "modifiers": modifiers,
667                "content_x": content_x,
668                "content_y": content_y,
669                "buffer_id": buffer_id,
670                "buffer_row": buffer_row,
671                "buffer_col": buffer_col,
672            })
673        }
674        HookArgs::MouseMove {
675            column,
676            row,
677            content_x,
678            content_y,
679        } => {
680            serde_json::json!({
681                "column": column,
682                "row": row,
683                "content_x": content_x,
684                "content_y": content_y,
685            })
686        }
687        HookArgs::LspServerRequest {
688            language,
689            method,
690            server_command,
691            params,
692        } => {
693            serde_json::json!({
694                "language": language,
695                "method": method,
696                "server_command": server_command,
697                "params": params,
698            })
699        }
700        HookArgs::ViewportChanged {
701            split_id,
702            buffer_id,
703            top_byte,
704            top_line,
705            width,
706            height,
707        } => {
708            serde_json::json!({
709                "split_id": split_id.0,
710                "buffer_id": buffer_id.0,
711                "top_byte": top_byte,
712                "top_line": top_line,
713                "width": width,
714                "height": height,
715            })
716        }
717        HookArgs::LspServerError {
718            language,
719            server_command,
720            error_type,
721            message,
722        } => {
723            serde_json::json!({
724                "language": language,
725                "server_command": server_command,
726                "error_type": error_type,
727                "message": message,
728            })
729        }
730        HookArgs::LspStatusClicked {
731            language,
732            has_error,
733            missing_servers,
734            user_dismissed,
735        } => {
736            serde_json::json!({
737                "language": language,
738                "has_error": has_error,
739                "missing_servers": missing_servers,
740                "user_dismissed": user_dismissed,
741            })
742        }
743        HookArgs::ActionPopupResult {
744            popup_id,
745            action_id,
746        } => {
747            serde_json::json!({
748                "popup_id": popup_id,
749                "action_id": action_id,
750            })
751        }
752        HookArgs::ProcessOutput { process_id, data } => {
753            serde_json::json!({
754                "process_id": process_id,
755                "data": data,
756            })
757        }
758        HookArgs::LanguageChanged {
759            buffer_id,
760            language,
761        } => {
762            serde_json::json!({
763                "buffer_id": buffer_id.0,
764                "language": language,
765            })
766        }
767        HookArgs::ThemeInspectKey { theme_name, key } => {
768            serde_json::json!({
769                "theme_name": theme_name,
770                "key": key,
771            })
772        }
773        HookArgs::MouseScroll {
774            buffer_id,
775            delta,
776            col,
777            row,
778        } => {
779            serde_json::json!({
780                "buffer_id": buffer_id.0,
781                "delta": delta,
782                "col": col,
783                "row": row,
784            })
785        }
786        HookArgs::Resize { width, height } => {
787            serde_json::json!({
788                "width": width,
789                "height": height,
790            })
791        }
792        HookArgs::FocusGained => {
793            serde_json::json!({})
794        }
795    };
796
797    Ok(json_value)
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use std::sync::atomic::{AtomicUsize, Ordering};
804    use std::sync::Arc;
805
806    fn noop_true() -> HookCallback {
807        Box::new(|_| true)
808    }
809
810    /// Adding, listing, counting, and removing hooks behave consistently:
811    /// counts match the number added, names reflect the keys, and removal
812    /// purges all callbacks for that key.
813    #[test]
814    fn add_count_list_remove_round_trip() {
815        let mut reg = HookRegistry::new();
816        assert_eq!(reg.hook_count("a"), 0);
817        assert!(reg.hook_names().is_empty());
818
819        reg.add_hook("a", noop_true());
820        reg.add_hook("a", noop_true());
821        reg.add_hook("b", noop_true());
822
823        assert_eq!(reg.hook_count("a"), 2);
824        assert_eq!(reg.hook_count("b"), 1);
825        assert_eq!(reg.hook_count("missing"), 0);
826
827        let mut names = reg.hook_names();
828        names.sort();
829        assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
830
831        reg.remove_hooks("a");
832        assert_eq!(reg.hook_count("a"), 0);
833        assert_eq!(reg.hook_count("b"), 1);
834        assert_eq!(reg.hook_names(), vec!["b".to_string()]);
835    }
836
837    /// `run_hooks` returns true iff every callback returned true, short-circuits
838    /// on the first `false`, and returns true for hook names with no callbacks.
839    #[test]
840    fn run_hooks_all_true_and_short_circuits_on_false() {
841        let mut reg = HookRegistry::new();
842        let args = HookArgs::EditorInitialized;
843
844        // Unknown hook: treated as "no callbacks" → true.
845        assert!(reg.run_hooks("unknown", &args));
846
847        // All-true chain returns true and calls every callback.
848        let calls = Arc::new(AtomicUsize::new(0));
849        for _ in 0..3 {
850            let c = calls.clone();
851            reg.add_hook(
852                "all_true",
853                Box::new(move |_| {
854                    c.fetch_add(1, Ordering::SeqCst);
855                    true
856                }),
857            );
858        }
859        assert!(reg.run_hooks("all_true", &args));
860        assert_eq!(calls.load(Ordering::SeqCst), 3);
861
862        // Short-circuits on the first `false` — the second callback must not run.
863        let calls = Arc::new(AtomicUsize::new(0));
864        let c1 = calls.clone();
865        reg.add_hook(
866            "short",
867            Box::new(move |_| {
868                c1.fetch_add(1, Ordering::SeqCst);
869                false
870            }),
871        );
872        let c2 = calls.clone();
873        reg.add_hook(
874            "short",
875            Box::new(move |_| {
876                c2.fetch_add(1, Ordering::SeqCst);
877                true
878            }),
879        );
880        assert!(!reg.run_hooks("short", &args));
881        assert_eq!(calls.load(Ordering::SeqCst), 1);
882    }
883
884    /// `hook_args_to_json` produces an object with the expected field for
885    /// a representative variant — ensuring the function actually serializes
886    /// the payload instead of returning a default (null) value.
887    #[test]
888    fn hook_args_to_json_serializes_payload_fields() {
889        let json = hook_args_to_json(&HookArgs::DiagnosticsUpdated {
890            uri: "file:///x.rs".into(),
891            count: 7,
892        })
893        .unwrap();
894        assert_eq!(json["uri"], "file:///x.rs");
895        assert_eq!(json["count"], 7);
896    }
897}