Skip to main content

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 crate::model::filesystem::StdFileSystem;
151    use std::sync::Arc;
152
153    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
154        Arc::new(StdFileSystem)
155    }
156    use super::*;
157    use crate::services::plugins::hooks::HookRegistry;
158    use fresh_core::CursorId;
159
160    #[test]
161    fn test_insert_event_has_hooks() {
162        let event = Event::Insert {
163            position: 0,
164            text: "test".to_string(),
165            cursor_id: CursorId(0),
166        };
167
168        let buffer_id = BufferId(1);
169
170        // Should have both before and after hooks
171        assert!(event.before_hook(buffer_id).is_some());
172        assert!(event.after_hook(buffer_id).is_some());
173    }
174
175    #[test]
176    fn test_delete_event_has_hooks() {
177        let event = Event::Delete {
178            range: 0..5,
179            deleted_text: "test".to_string(),
180            cursor_id: CursorId(0),
181        };
182
183        let buffer_id = BufferId(1);
184
185        assert!(event.before_hook(buffer_id).is_some());
186        assert!(event.after_hook(buffer_id).is_some());
187    }
188
189    #[test]
190    fn test_overlay_event_no_hooks() {
191        let event = Event::AddOverlay {
192            range: 0..5,
193            face: crate::model::event::OverlayFace::Background { color: (255, 0, 0) },
194            priority: 10,
195            message: None,
196            extend_to_line_end: false,
197            namespace: None,
198        };
199
200        let buffer_id = BufferId(1);
201
202        // Overlay events don't trigger hooks (they're visual only)
203        assert!(event.before_hook(buffer_id).is_none());
204        assert!(event.after_hook(buffer_id).is_none());
205    }
206
207    #[test]
208    fn test_hooks_can_cancel() {
209        use crate::state::EditorState;
210        use std::sync::RwLock;
211
212        let mut state = EditorState::new(
213            80,
214            24,
215            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
216            test_fs(),
217        );
218        let hook_registry = RwLock::new(HookRegistry::new());
219
220        // Register a hook that cancels the operation
221        {
222            let mut registry = hook_registry.write().unwrap();
223            registry.add_hook("before_insert", Box::new(|_| false)); // Return false to cancel
224        }
225
226        let event = Event::Insert {
227            position: 0,
228            text: "test".to_string(),
229            cursor_id: CursorId(0),
230        };
231
232        let buffer_id = BufferId(0);
233        let was_applied = apply_event_with_hooks(&mut state, &event, buffer_id, &hook_registry);
234
235        // Event should have been cancelled
236        assert!(!was_applied);
237        assert_eq!(state.buffer.len(), 0); // Buffer should still be empty
238    }
239
240    #[test]
241    fn test_hooks_allow_event() {
242        use crate::state::EditorState;
243        use std::sync::RwLock;
244
245        let mut state = EditorState::new(
246            80,
247            24,
248            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
249            test_fs(),
250        );
251        let hook_registry = RwLock::new(HookRegistry::new());
252
253        // Register a hook that allows the operation
254        {
255            let mut registry = hook_registry.write().unwrap();
256            registry.add_hook("before_insert", Box::new(|_| true)); // Return true to allow
257        }
258
259        let event = Event::Insert {
260            position: 0,
261            text: "test".to_string(),
262            cursor_id: CursorId(0),
263        };
264
265        let buffer_id = BufferId(0);
266        let was_applied = apply_event_with_hooks(&mut state, &event, buffer_id, &hook_registry);
267
268        // Event should have been applied
269        assert!(was_applied);
270        assert_eq!(state.buffer.to_string().unwrap(), "test");
271    }
272}