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                // Placeholders - will be filled by caller with buffer access
83                line: 0,
84                text_properties: Vec::new(),
85            }),
86            _ => None,
87        }
88    }
89}
90
91/// Apply an event with automatic hook invocations
92pub fn apply_event_with_hooks(
93    state: &mut crate::state::EditorState,
94    cursors: &mut crate::model::cursor::Cursors,
95    event: &Event,
96    buffer_id: BufferId,
97    hook_registry: &RwLock<HookRegistry>,
98) -> bool {
99    // Run "before" hooks
100    if let Some(before_args) = event.before_hook(buffer_id) {
101        let registry = hook_registry.read().unwrap();
102        let hook_name = match &before_args {
103            HookArgs::BeforeInsert { .. } => "before_insert",
104            HookArgs::BeforeDelete { .. } => "before_delete",
105            _ => "",
106        };
107
108        if !hook_name.is_empty() {
109            let should_continue = registry.run_hooks(hook_name, &before_args);
110            if !should_continue {
111                // Hook cancelled the operation
112                return false;
113            }
114        }
115    }
116
117    // Apply the event
118    state.apply(cursors, event);
119
120    // Run "after" hooks
121    if let Some(mut after_args) = event.after_hook(buffer_id) {
122        // Fill in line number and text properties for CursorMoved events
123        if let HookArgs::CursorMoved {
124            new_position,
125            ref mut line,
126            ref mut text_properties,
127            ..
128        } = after_args
129        {
130            // Compute 1-indexed line number from byte position
131            // get_line_number returns 0-indexed, so add 1
132            *line = state.buffer.get_line_number(new_position) + 1;
133            // Include text properties at cursor position
134            *text_properties = state
135                .text_properties
136                .get_at(new_position)
137                .into_iter()
138                .map(|tp| tp.properties.clone())
139                .collect();
140        }
141
142        let registry = hook_registry.read().unwrap();
143        let hook_name = match &after_args {
144            HookArgs::AfterInsert { .. } => "after_insert",
145            HookArgs::AfterDelete { .. } => "after_delete",
146            HookArgs::CursorMoved { .. } => "cursor_moved",
147            _ => "",
148        };
149
150        if !hook_name.is_empty() {
151            registry.run_hooks(hook_name, &after_args);
152        }
153    }
154
155    true // Event was applied
156}
157
158#[cfg(test)]
159mod tests {
160    use crate::model::filesystem::StdFileSystem;
161    use std::sync::Arc;
162
163    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
164        Arc::new(StdFileSystem)
165    }
166    use super::*;
167    use crate::services::plugins::hooks::HookRegistry;
168    use fresh_core::CursorId;
169
170    #[test]
171    fn test_insert_event_has_hooks() {
172        let event = Event::Insert {
173            position: 0,
174            text: "test".to_string(),
175            cursor_id: CursorId(0),
176        };
177
178        let buffer_id = BufferId(1);
179
180        // Should have both before and after hooks
181        assert!(event.before_hook(buffer_id).is_some());
182        assert!(event.after_hook(buffer_id).is_some());
183    }
184
185    #[test]
186    fn test_delete_event_has_hooks() {
187        let event = Event::Delete {
188            range: 0..5,
189            deleted_text: "test".to_string(),
190            cursor_id: CursorId(0),
191        };
192
193        let buffer_id = BufferId(1);
194
195        assert!(event.before_hook(buffer_id).is_some());
196        assert!(event.after_hook(buffer_id).is_some());
197    }
198
199    #[test]
200    fn test_overlay_event_no_hooks() {
201        let event = Event::AddOverlay {
202            range: 0..5,
203            face: crate::model::event::OverlayFace::Background { color: (255, 0, 0) },
204            priority: 10,
205            message: None,
206            extend_to_line_end: false,
207            namespace: None,
208            url: None,
209        };
210
211        let buffer_id = BufferId(1);
212
213        // Overlay events don't trigger hooks (they're visual only)
214        assert!(event.before_hook(buffer_id).is_none());
215        assert!(event.after_hook(buffer_id).is_none());
216    }
217
218    #[test]
219    fn test_hooks_can_cancel() {
220        use crate::state::EditorState;
221        use std::sync::RwLock;
222
223        let mut state = EditorState::new(
224            80,
225            24,
226            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
227            test_fs(),
228        );
229        let hook_registry = RwLock::new(HookRegistry::new());
230
231        // Register a hook that cancels the operation
232        {
233            let mut registry = hook_registry.write().unwrap();
234            registry.add_hook("before_insert", Box::new(|_| false)); // Return false to cancel
235        }
236
237        let event = Event::Insert {
238            position: 0,
239            text: "test".to_string(),
240            cursor_id: CursorId(0),
241        };
242
243        let buffer_id = BufferId(0);
244        let mut cursors = crate::model::cursor::Cursors::new();
245        let was_applied =
246            apply_event_with_hooks(&mut state, &mut cursors, &event, buffer_id, &hook_registry);
247
248        // Event should have been cancelled
249        assert!(!was_applied);
250        assert_eq!(state.buffer.len(), 0); // Buffer should still be empty
251    }
252
253    #[test]
254    fn test_hooks_allow_event() {
255        use crate::state::EditorState;
256        use std::sync::RwLock;
257
258        let mut state = EditorState::new(
259            80,
260            24,
261            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
262            test_fs(),
263        );
264        let hook_registry = RwLock::new(HookRegistry::new());
265
266        // Register a hook that allows the operation
267        {
268            let mut registry = hook_registry.write().unwrap();
269            registry.add_hook("before_insert", Box::new(|_| true)); // Return true to allow
270        }
271
272        let event = Event::Insert {
273            position: 0,
274            text: "test".to_string(),
275            cursor_id: CursorId(0),
276        };
277
278        let buffer_id = BufferId(0);
279        let mut cursors = crate::model::cursor::Cursors::new();
280        let was_applied =
281            apply_event_with_hooks(&mut state, &mut cursors, &event, buffer_id, &hook_registry);
282
283        // Event should have been applied
284        assert!(was_applied);
285        assert_eq!(state.buffer.to_string().unwrap(), "test");
286    }
287}