fresh/services/plugins/
event_hooks.rs

1//! Event-to-Hook Mapping
2//!
3//! This module maps editor Events to Hook invocations automatically.
4//! This ensures hooks are triggered consistently whenever state changes occur.
5
6use crate::model::event::Event;
7use crate::services::plugins::hooks::{HookArgs, HookRegistry};
8use fresh_core::BufferId;
9use std::sync::RwLock;
10
11/// Trait for converting Events into Hook invocations
12pub trait EventHooks {
13    /// Get the "before" hook args for this event (if any)
14    fn before_hook(&self, buffer_id: BufferId) -> Option<HookArgs>;
15
16    /// Get the "after" hook args for this event (if any)
17    fn after_hook(&self, buffer_id: BufferId) -> Option<HookArgs>;
18}
19
20impl EventHooks for Event {
21    fn before_hook(&self, buffer_id: BufferId) -> Option<HookArgs> {
22        match self {
23            Self::Insert {
24                position,
25                text,
26                cursor_id: _,
27            } => Some(HookArgs::BeforeInsert {
28                buffer_id,
29                position: *position,
30                text: text.clone(),
31            }),
32            Self::Delete { range, .. } => Some(HookArgs::BeforeDelete {
33                buffer_id,
34                range: range.clone(),
35            }),
36            _ => None, // Most events don't have "before" hooks
37        }
38    }
39
40    fn after_hook(&self, buffer_id: BufferId) -> Option<HookArgs> {
41        match self {
42            Self::Insert {
43                position,
44                text,
45                cursor_id: _,
46            } => Some(HookArgs::AfterInsert {
47                buffer_id,
48                position: *position,
49                text: text.clone(),
50                affected_start: *position,
51                affected_end: *position + text.len(),
52                // Line info placeholder - will be filled by caller with buffer access
53                start_line: 0,
54                end_line: 0,
55                lines_added: 0,
56            }),
57            Self::Delete {
58                range,
59                deleted_text,
60                ..
61            } => Some(HookArgs::AfterDelete {
62                buffer_id,
63                range: range.clone(),
64                deleted_text: deleted_text.clone(),
65                affected_start: range.start,
66                deleted_len: deleted_text.len(),
67                // Line info placeholder - will be filled by caller with buffer access
68                start_line: 0,
69                end_line: 0,
70                lines_removed: 0,
71            }),
72            Self::MoveCursor {
73                cursor_id,
74                old_position,
75                new_position,
76                ..
77            } => Some(HookArgs::CursorMoved {
78                buffer_id,
79                cursor_id: *cursor_id,
80                old_position: *old_position,
81                new_position: *new_position,
82                // Line placeholder - will be filled by caller with buffer access
83                line: 0,
84            }),
85            _ => None,
86        }
87    }
88}
89
90/// Apply an event with automatic hook invocations
91pub fn apply_event_with_hooks(
92    state: &mut crate::state::EditorState,
93    event: &Event,
94    buffer_id: BufferId,
95    hook_registry: &RwLock<HookRegistry>,
96) -> bool {
97    // Run "before" hooks
98    if let Some(before_args) = event.before_hook(buffer_id) {
99        let registry = hook_registry.read().unwrap();
100        let hook_name = match &before_args {
101            HookArgs::BeforeInsert { .. } => "before_insert",
102            HookArgs::BeforeDelete { .. } => "before_delete",
103            _ => "",
104        };
105
106        if !hook_name.is_empty() {
107            let should_continue = registry.run_hooks(hook_name, &before_args);
108            if !should_continue {
109                // Hook cancelled the operation
110                return false;
111            }
112        }
113    }
114
115    // Apply the event
116    state.apply(event);
117
118    // Run "after" hooks
119    if let Some(mut after_args) = event.after_hook(buffer_id) {
120        // Fill in line number for CursorMoved events
121        if let HookArgs::CursorMoved {
122            new_position,
123            ref mut line,
124            ..
125        } = after_args
126        {
127            // Compute 1-indexed line number from byte position
128            // get_line_number returns 0-indexed, so add 1
129            *line = state.buffer.get_line_number(new_position) + 1;
130        }
131
132        let registry = hook_registry.read().unwrap();
133        let hook_name = match &after_args {
134            HookArgs::AfterInsert { .. } => "after_insert",
135            HookArgs::AfterDelete { .. } => "after_delete",
136            HookArgs::CursorMoved { .. } => "cursor_moved",
137            _ => "",
138        };
139
140        if !hook_name.is_empty() {
141            registry.run_hooks(hook_name, &after_args);
142        }
143    }
144
145    true // Event was applied
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::services::plugins::hooks::HookRegistry;
152    use fresh_core::CursorId;
153
154    #[test]
155    fn test_insert_event_has_hooks() {
156        let event = Event::Insert {
157            position: 0,
158            text: "test".to_string(),
159            cursor_id: CursorId(0),
160        };
161
162        let buffer_id = BufferId(1);
163
164        // Should have both before and after hooks
165        assert!(event.before_hook(buffer_id).is_some());
166        assert!(event.after_hook(buffer_id).is_some());
167    }
168
169    #[test]
170    fn test_delete_event_has_hooks() {
171        let event = Event::Delete {
172            range: 0..5,
173            deleted_text: "test".to_string(),
174            cursor_id: CursorId(0),
175        };
176
177        let buffer_id = BufferId(1);
178
179        assert!(event.before_hook(buffer_id).is_some());
180        assert!(event.after_hook(buffer_id).is_some());
181    }
182
183    #[test]
184    fn test_overlay_event_no_hooks() {
185        let event = Event::AddOverlay {
186            range: 0..5,
187            face: crate::model::event::OverlayFace::Background { color: (255, 0, 0) },
188            priority: 10,
189            message: None,
190            extend_to_line_end: false,
191            namespace: None,
192        };
193
194        let buffer_id = BufferId(1);
195
196        // Overlay events don't trigger hooks (they're visual only)
197        assert!(event.before_hook(buffer_id).is_none());
198        assert!(event.after_hook(buffer_id).is_none());
199    }
200
201    #[test]
202    fn test_hooks_can_cancel() {
203        use crate::state::EditorState;
204        use std::sync::RwLock;
205
206        let mut state =
207            EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
208        let hook_registry = RwLock::new(HookRegistry::new());
209
210        // Register a hook that cancels the operation
211        {
212            let mut registry = hook_registry.write().unwrap();
213            registry.add_hook("before_insert", Box::new(|_| false)); // Return false to cancel
214        }
215
216        let event = Event::Insert {
217            position: 0,
218            text: "test".to_string(),
219            cursor_id: CursorId(0),
220        };
221
222        let buffer_id = BufferId(0);
223        let was_applied = apply_event_with_hooks(&mut state, &event, buffer_id, &hook_registry);
224
225        // Event should have been cancelled
226        assert!(!was_applied);
227        assert_eq!(state.buffer.len(), 0); // Buffer should still be empty
228    }
229
230    #[test]
231    fn test_hooks_allow_event() {
232        use crate::state::EditorState;
233        use std::sync::RwLock;
234
235        let mut state =
236            EditorState::new(80, 24, crate::config::LARGE_FILE_THRESHOLD_BYTES as usize);
237        let hook_registry = RwLock::new(HookRegistry::new());
238
239        // Register a hook that allows the operation
240        {
241            let mut registry = hook_registry.write().unwrap();
242            registry.add_hook("before_insert", Box::new(|_| true)); // Return true to allow
243        }
244
245        let event = Event::Insert {
246            position: 0,
247            text: "test".to_string(),
248            cursor_id: CursorId(0),
249        };
250
251        let buffer_id = BufferId(0);
252        let was_applied = apply_event_with_hooks(&mut state, &event, buffer_id, &hook_registry);
253
254        // Event should have been applied
255        assert!(was_applied);
256        assert_eq!(state.buffer.to_string().unwrap(), "test");
257    }
258}