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