Skip to main content

rgx/
app.rs

1use std::collections::VecDeque;
2use std::time::{Duration, Instant};
3
4use crate::engine::{self, CompiledRegex, EngineFlags, EngineKind, RegexEngine};
5use crate::explain::{self, ExplainNode};
6use crate::input::editor::Editor;
7
8#[derive(Debug, Clone)]
9pub struct BenchmarkResult {
10    pub engine: EngineKind,
11    pub compile_time: Duration,
12    pub match_time: Duration,
13    pub match_count: usize,
14    pub error: Option<String>,
15}
16
17fn truncate(s: &str, max_chars: usize) -> String {
18    let char_count = s.chars().count();
19    if char_count <= max_chars {
20        s.to_string()
21    } else {
22        let end = s
23            .char_indices()
24            .nth(max_chars)
25            .map(|(i, _)| i)
26            .unwrap_or(s.len());
27        format!("{}...", &s[..end])
28    }
29}
30
31pub struct App {
32    pub regex_editor: Editor,
33    pub test_editor: Editor,
34    pub replace_editor: Editor,
35    pub focused_panel: u8,
36    pub engine_kind: EngineKind,
37    pub flags: EngineFlags,
38    pub matches: Vec<engine::Match>,
39    pub replace_result: Option<engine::ReplaceResult>,
40    pub explanation: Vec<ExplainNode>,
41    pub error: Option<String>,
42    pub show_help: bool,
43    pub help_page: usize,
44    pub should_quit: bool,
45    pub match_scroll: u16,
46    pub replace_scroll: u16,
47    pub explain_scroll: u16,
48    // Pattern history
49    pub pattern_history: VecDeque<String>,
50    pub history_index: Option<usize>,
51    history_temp: Option<String>,
52    // Match selection + clipboard
53    pub selected_match: usize,
54    pub selected_capture: Option<usize>,
55    pub clipboard_status: Option<String>,
56    clipboard_status_ticks: u32,
57    pub show_whitespace: bool,
58    pub rounded_borders: bool,
59    pub vim_mode: bool,
60    pub vim_state: crate::input::vim::VimState,
61    pub compile_time: Option<Duration>,
62    pub match_time: Option<Duration>,
63    pub error_offset: Option<usize>,
64    pub output_on_quit: bool,
65    pub workspace_path: Option<String>,
66    pub show_recipes: bool,
67    pub recipe_index: usize,
68    pub show_benchmark: bool,
69    pub benchmark_results: Vec<BenchmarkResult>,
70    engine: Box<dyn RegexEngine>,
71    compiled: Option<Box<dyn CompiledRegex>>,
72}
73
74impl App {
75    pub const PANEL_REGEX: u8 = 0;
76    pub const PANEL_TEST: u8 = 1;
77    pub const PANEL_REPLACE: u8 = 2;
78    pub const PANEL_MATCHES: u8 = 3;
79    pub const PANEL_EXPLAIN: u8 = 4;
80    pub const PANEL_COUNT: u8 = 5;
81}
82
83impl App {
84    pub fn new(engine_kind: EngineKind, flags: EngineFlags) -> Self {
85        let engine = engine::create_engine(engine_kind);
86        Self {
87            regex_editor: Editor::new(),
88            test_editor: Editor::new(),
89            replace_editor: Editor::new(),
90            focused_panel: 0,
91            engine_kind,
92            flags,
93            matches: Vec::new(),
94            replace_result: None,
95            explanation: Vec::new(),
96            error: None,
97            show_help: false,
98            help_page: 0,
99            should_quit: false,
100            match_scroll: 0,
101            replace_scroll: 0,
102            explain_scroll: 0,
103            pattern_history: VecDeque::new(),
104            history_index: None,
105            history_temp: None,
106            selected_match: 0,
107            selected_capture: None,
108            clipboard_status: None,
109            clipboard_status_ticks: 0,
110            show_whitespace: false,
111            rounded_borders: false,
112            vim_mode: false,
113            vim_state: crate::input::vim::VimState::new(),
114            compile_time: None,
115            match_time: None,
116            error_offset: None,
117            output_on_quit: false,
118            workspace_path: None,
119            show_recipes: false,
120            recipe_index: 0,
121            show_benchmark: false,
122            benchmark_results: Vec::new(),
123            engine,
124            compiled: None,
125        }
126    }
127
128    pub fn set_replacement(&mut self, text: &str) {
129        self.replace_editor = Editor::with_content(text.to_string());
130        self.rereplace();
131    }
132
133    pub fn scroll_replace_up(&mut self) {
134        self.replace_scroll = self.replace_scroll.saturating_sub(1);
135    }
136
137    pub fn scroll_replace_down(&mut self) {
138        self.replace_scroll = self.replace_scroll.saturating_add(1);
139    }
140
141    pub fn rereplace(&mut self) {
142        let template = self.replace_editor.content().to_string();
143        if template.is_empty() || self.matches.is_empty() {
144            self.replace_result = None;
145            return;
146        }
147        let text = self.test_editor.content().to_string();
148        self.replace_result = Some(engine::replace_all(&text, &self.matches, &template));
149    }
150
151    pub fn set_pattern(&mut self, pattern: &str) {
152        self.regex_editor = Editor::with_content(pattern.to_string());
153        self.recompute();
154    }
155
156    pub fn set_test_string(&mut self, text: &str) {
157        self.test_editor = Editor::with_content(text.to_string());
158        self.rematch();
159    }
160
161    pub fn switch_engine(&mut self) {
162        self.engine_kind = self.engine_kind.next();
163        self.engine = engine::create_engine(self.engine_kind);
164        self.recompute();
165    }
166
167    pub fn switch_engine_to(&mut self, kind: EngineKind) {
168        self.engine_kind = kind;
169        self.engine = engine::create_engine(kind);
170    }
171
172    pub fn scroll_match_up(&mut self) {
173        self.match_scroll = self.match_scroll.saturating_sub(1);
174    }
175
176    pub fn scroll_match_down(&mut self) {
177        self.match_scroll = self.match_scroll.saturating_add(1);
178    }
179
180    pub fn scroll_explain_up(&mut self) {
181        self.explain_scroll = self.explain_scroll.saturating_sub(1);
182    }
183
184    pub fn scroll_explain_down(&mut self) {
185        self.explain_scroll = self.explain_scroll.saturating_add(1);
186    }
187
188    pub fn recompute(&mut self) {
189        let pattern = self.regex_editor.content().to_string();
190        self.match_scroll = 0;
191        self.explain_scroll = 0;
192        self.error_offset = None;
193
194        if pattern.is_empty() {
195            self.compiled = None;
196            self.matches.clear();
197            self.explanation.clear();
198            self.error = None;
199            self.compile_time = None;
200            self.match_time = None;
201            return;
202        }
203
204        // Compile
205        let compile_start = Instant::now();
206        match self.engine.compile(&pattern, &self.flags) {
207            Ok(compiled) => {
208                self.compile_time = Some(compile_start.elapsed());
209                self.compiled = Some(compiled);
210                self.error = None;
211            }
212            Err(e) => {
213                self.compile_time = Some(compile_start.elapsed());
214                self.compiled = None;
215                self.matches.clear();
216                self.error = Some(e.to_string());
217            }
218        }
219
220        // Explain (uses regex-syntax, independent of engine)
221        match explain::explain(&pattern) {
222            Ok(nodes) => self.explanation = nodes,
223            Err((msg, offset)) => {
224                self.explanation.clear();
225                if self.error_offset.is_none() {
226                    self.error_offset = offset;
227                }
228                if self.error.is_none() {
229                    self.error = Some(msg);
230                }
231            }
232        }
233
234        // Match
235        self.rematch();
236    }
237
238    pub fn rematch(&mut self) {
239        self.match_scroll = 0;
240        self.selected_match = 0;
241        self.selected_capture = None;
242        if let Some(compiled) = &self.compiled {
243            let text = self.test_editor.content().to_string();
244            if text.is_empty() {
245                self.matches.clear();
246                self.replace_result = None;
247                self.match_time = None;
248                return;
249            }
250            let match_start = Instant::now();
251            match compiled.find_matches(&text) {
252                Ok(m) => {
253                    self.match_time = Some(match_start.elapsed());
254                    self.matches = m;
255                }
256                Err(e) => {
257                    self.match_time = Some(match_start.elapsed());
258                    self.matches.clear();
259                    self.error = Some(e.to_string());
260                }
261            }
262        } else {
263            self.matches.clear();
264            self.match_time = None;
265        }
266        self.rereplace();
267    }
268
269    // --- Pattern history ---
270
271    pub fn commit_pattern_to_history(&mut self) {
272        let pattern = self.regex_editor.content().to_string();
273        if pattern.is_empty() {
274            return;
275        }
276        if self.pattern_history.back().map(|s| s.as_str()) == Some(&pattern) {
277            return;
278        }
279        self.pattern_history.push_back(pattern);
280        if self.pattern_history.len() > 100 {
281            self.pattern_history.pop_front();
282        }
283        self.history_index = None;
284        self.history_temp = None;
285    }
286
287    pub fn history_prev(&mut self) {
288        if self.pattern_history.is_empty() {
289            return;
290        }
291        let new_index = match self.history_index {
292            Some(0) => return,
293            Some(idx) => idx - 1,
294            None => {
295                self.history_temp = Some(self.regex_editor.content().to_string());
296                self.pattern_history.len() - 1
297            }
298        };
299        self.history_index = Some(new_index);
300        let pattern = self.pattern_history[new_index].clone();
301        self.regex_editor = Editor::with_content(pattern);
302        self.recompute();
303    }
304
305    pub fn history_next(&mut self) {
306        let idx = match self.history_index {
307            Some(idx) => idx,
308            None => return,
309        };
310        if idx + 1 < self.pattern_history.len() {
311            let new_index = idx + 1;
312            self.history_index = Some(new_index);
313            let pattern = self.pattern_history[new_index].clone();
314            self.regex_editor = Editor::with_content(pattern);
315            self.recompute();
316        } else {
317            // Past end — restore temp
318            self.history_index = None;
319            let content = self.history_temp.take().unwrap_or_default();
320            self.regex_editor = Editor::with_content(content);
321            self.recompute();
322        }
323    }
324
325    // --- Match selection + clipboard ---
326
327    pub fn select_match_next(&mut self) {
328        if self.matches.is_empty() {
329            return;
330        }
331        match self.selected_capture {
332            None => {
333                let m = &self.matches[self.selected_match];
334                if !m.captures.is_empty() {
335                    self.selected_capture = Some(0);
336                } else if self.selected_match + 1 < self.matches.len() {
337                    self.selected_match += 1;
338                }
339            }
340            Some(ci) => {
341                let m = &self.matches[self.selected_match];
342                if ci + 1 < m.captures.len() {
343                    self.selected_capture = Some(ci + 1);
344                } else if self.selected_match + 1 < self.matches.len() {
345                    self.selected_match += 1;
346                    self.selected_capture = None;
347                }
348            }
349        }
350        self.scroll_to_selected();
351    }
352
353    pub fn select_match_prev(&mut self) {
354        if self.matches.is_empty() {
355            return;
356        }
357        match self.selected_capture {
358            Some(0) => {
359                self.selected_capture = None;
360            }
361            Some(ci) => {
362                self.selected_capture = Some(ci - 1);
363            }
364            None => {
365                if self.selected_match > 0 {
366                    self.selected_match -= 1;
367                    let m = &self.matches[self.selected_match];
368                    if !m.captures.is_empty() {
369                        self.selected_capture = Some(m.captures.len() - 1);
370                    }
371                }
372            }
373        }
374        self.scroll_to_selected();
375    }
376
377    fn scroll_to_selected(&mut self) {
378        if self.matches.is_empty() || self.selected_match >= self.matches.len() {
379            return;
380        }
381        let mut line = 0usize;
382        for i in 0..self.selected_match {
383            line += 1 + self.matches[i].captures.len();
384        }
385        if let Some(ci) = self.selected_capture {
386            line += 1 + ci;
387        }
388        self.match_scroll = u16::try_from(line).unwrap_or(u16::MAX);
389    }
390
391    pub fn copy_selected_match(&mut self) {
392        let text = self.selected_text();
393        let Some(text) = text else { return };
394        match arboard::Clipboard::new() {
395            Ok(mut cb) => match cb.set_text(&text) {
396                Ok(()) => {
397                    self.clipboard_status = Some(format!("Copied: \"{}\"", truncate(&text, 40)));
398                    self.clipboard_status_ticks = 40; // ~2 sec at 50ms tick
399                }
400                Err(e) => {
401                    self.clipboard_status = Some(format!("Clipboard error: {e}"));
402                    self.clipboard_status_ticks = 40;
403                }
404            },
405            Err(e) => {
406                self.clipboard_status = Some(format!("Clipboard error: {e}"));
407                self.clipboard_status_ticks = 40;
408            }
409        }
410    }
411
412    pub fn set_status_message(&mut self, message: String) {
413        self.clipboard_status = Some(message);
414        self.clipboard_status_ticks = 40; // ~2 sec at 50ms tick
415    }
416
417    /// Tick down the clipboard status timer. Returns true if status was cleared.
418    pub fn tick_clipboard_status(&mut self) -> bool {
419        if self.clipboard_status.is_some() {
420            if self.clipboard_status_ticks > 0 {
421                self.clipboard_status_ticks -= 1;
422            } else {
423                self.clipboard_status = None;
424                return true;
425            }
426        }
427        false
428    }
429
430    /// Print match results or replacement output to stdout.
431    pub fn print_output(&self, group: Option<&str>, count: bool) {
432        if count {
433            println!("{}", self.matches.len());
434            return;
435        }
436        if let Some(ref result) = self.replace_result {
437            print!("{}", result.output);
438        } else if let Some(group_spec) = group {
439            for m in &self.matches {
440                if let Some(text) = engine::lookup_capture(m, group_spec) {
441                    println!("{text}");
442                } else {
443                    eprintln!("rgx: group '{group_spec}' not found in match");
444                }
445            }
446        } else {
447            for m in &self.matches {
448                println!("{}", m.text);
449            }
450        }
451    }
452
453    fn selected_text(&self) -> Option<String> {
454        let m = self.matches.get(self.selected_match)?;
455        match self.selected_capture {
456            None => Some(m.text.clone()),
457            Some(ci) => m.captures.get(ci).map(|c| c.text.clone()),
458        }
459    }
460
461    /// Apply a mutating editor operation to the currently focused editor panel,
462    /// then trigger the appropriate recompute/rematch/rereplace.
463    pub fn edit_focused(&mut self, f: impl FnOnce(&mut Editor)) {
464        match self.focused_panel {
465            Self::PANEL_REGEX => {
466                f(&mut self.regex_editor);
467                self.recompute();
468            }
469            Self::PANEL_TEST => {
470                f(&mut self.test_editor);
471                self.rematch();
472            }
473            Self::PANEL_REPLACE => {
474                f(&mut self.replace_editor);
475                self.rereplace();
476            }
477            _ => {}
478        }
479    }
480
481    /// Apply a non-mutating cursor movement to the currently focused editor panel.
482    pub fn move_focused(&mut self, f: impl FnOnce(&mut Editor)) {
483        match self.focused_panel {
484            Self::PANEL_REGEX => f(&mut self.regex_editor),
485            Self::PANEL_TEST => f(&mut self.test_editor),
486            Self::PANEL_REPLACE => f(&mut self.replace_editor),
487            _ => {}
488        }
489    }
490
491    pub fn run_benchmark(&mut self) {
492        let pattern = self.regex_editor.content().to_string();
493        let text = self.test_editor.content().to_string();
494        if pattern.is_empty() || text.is_empty() {
495            return;
496        }
497
498        let mut results = Vec::new();
499        for kind in EngineKind::all() {
500            let eng = engine::create_engine(kind);
501            let compile_start = Instant::now();
502            let compiled = match eng.compile(&pattern, &self.flags) {
503                Ok(c) => c,
504                Err(e) => {
505                    results.push(BenchmarkResult {
506                        engine: kind,
507                        compile_time: compile_start.elapsed(),
508                        match_time: Duration::ZERO,
509                        match_count: 0,
510                        error: Some(e.to_string()),
511                    });
512                    continue;
513                }
514            };
515            let compile_time = compile_start.elapsed();
516            let match_start = Instant::now();
517            let (match_count, error) = match compiled.find_matches(&text) {
518                Ok(matches) => (matches.len(), None),
519                Err(e) => (0, Some(e.to_string())),
520            };
521            results.push(BenchmarkResult {
522                engine: kind,
523                compile_time,
524                match_time: match_start.elapsed(),
525                match_count,
526                error,
527            });
528        }
529        self.benchmark_results = results;
530        self.show_benchmark = true;
531    }
532}