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