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, color: bool) {
432        if count {
433            println!("{}", self.matches.len());
434            return;
435        }
436        if let Some(ref result) = self.replace_result {
437            if color {
438                print_colored_replace(&result.output, &result.segments);
439            } else {
440                print!("{}", result.output);
441            }
442        } else if let Some(group_spec) = group {
443            for m in &self.matches {
444                if let Some(text) = engine::lookup_capture(m, group_spec) {
445                    if color {
446                        println!("\x1b[1;31m{text}\x1b[0m");
447                    } else {
448                        println!("{text}");
449                    }
450                } else {
451                    eprintln!("rgx: group '{group_spec}' not found in match");
452                }
453            }
454        } else if color {
455            let text = self.test_editor.content();
456            print_colored_matches(text, &self.matches);
457        } else {
458            for m in &self.matches {
459                println!("{}", m.text);
460            }
461        }
462    }
463
464    /// Print matches as structured JSON.
465    pub fn print_json_output(&self) {
466        let json_matches: Vec<serde_json::Value> = self
467            .matches
468            .iter()
469            .map(|m| {
470                let groups: Vec<serde_json::Value> = m
471                    .captures
472                    .iter()
473                    .map(|c| {
474                        let mut obj = serde_json::json!({
475                            "group": c.index,
476                            "value": c.text,
477                            "start": c.start,
478                            "end": c.end,
479                        });
480                        if let Some(ref name) = c.name {
481                            obj["name"] = serde_json::json!(name);
482                        }
483                        obj
484                    })
485                    .collect();
486                serde_json::json!({
487                    "match": m.text,
488                    "start": m.start,
489                    "end": m.end,
490                    "groups": groups,
491                })
492            })
493            .collect();
494        println!(
495            "{}",
496            serde_json::to_string_pretty(&json_matches).unwrap_or_else(|_| "[]".to_string())
497        );
498    }
499
500    fn selected_text(&self) -> Option<String> {
501        let m = self.matches.get(self.selected_match)?;
502        match self.selected_capture {
503            None => Some(m.text.clone()),
504            Some(ci) => m.captures.get(ci).map(|c| c.text.clone()),
505        }
506    }
507
508    /// Apply a mutating editor operation to the currently focused editor panel,
509    /// then trigger the appropriate recompute/rematch/rereplace.
510    pub fn edit_focused(&mut self, f: impl FnOnce(&mut Editor)) {
511        match self.focused_panel {
512            Self::PANEL_REGEX => {
513                f(&mut self.regex_editor);
514                self.recompute();
515            }
516            Self::PANEL_TEST => {
517                f(&mut self.test_editor);
518                self.rematch();
519            }
520            Self::PANEL_REPLACE => {
521                f(&mut self.replace_editor);
522                self.rereplace();
523            }
524            _ => {}
525        }
526    }
527
528    /// Apply a non-mutating cursor movement to the currently focused editor panel.
529    pub fn move_focused(&mut self, f: impl FnOnce(&mut Editor)) {
530        match self.focused_panel {
531            Self::PANEL_REGEX => f(&mut self.regex_editor),
532            Self::PANEL_TEST => f(&mut self.test_editor),
533            Self::PANEL_REPLACE => f(&mut self.replace_editor),
534            _ => {}
535        }
536    }
537
538    pub fn run_benchmark(&mut self) {
539        let pattern = self.regex_editor.content().to_string();
540        let text = self.test_editor.content().to_string();
541        if pattern.is_empty() || text.is_empty() {
542            return;
543        }
544
545        let mut results = Vec::new();
546        for kind in EngineKind::all() {
547            let eng = engine::create_engine(kind);
548            let compile_start = Instant::now();
549            let compiled = match eng.compile(&pattern, &self.flags) {
550                Ok(c) => c,
551                Err(e) => {
552                    results.push(BenchmarkResult {
553                        engine: kind,
554                        compile_time: compile_start.elapsed(),
555                        match_time: Duration::ZERO,
556                        match_count: 0,
557                        error: Some(e.to_string()),
558                    });
559                    continue;
560                }
561            };
562            let compile_time = compile_start.elapsed();
563            let match_start = Instant::now();
564            let (match_count, error) = match compiled.find_matches(&text) {
565                Ok(matches) => (matches.len(), None),
566                Err(e) => (0, Some(e.to_string())),
567            };
568            results.push(BenchmarkResult {
569                engine: kind,
570                compile_time,
571                match_time: match_start.elapsed(),
572                match_count,
573                error,
574            });
575        }
576        self.benchmark_results = results;
577        self.show_benchmark = true;
578    }
579
580    /// Generate a regex101.com URL from the current state.
581    pub fn regex101_url(&self) -> String {
582        let pattern = self.regex_editor.content();
583        let test_string = self.test_editor.content();
584
585        let flavor = match self.engine_kind {
586            #[cfg(feature = "pcre2-engine")]
587            EngineKind::Pcre2 => "pcre2",
588            _ => "ecmascript",
589        };
590
591        let mut flags = String::from("g");
592        if self.flags.case_insensitive {
593            flags.push('i');
594        }
595        if self.flags.multi_line {
596            flags.push('m');
597        }
598        if self.flags.dot_matches_newline {
599            flags.push('s');
600        }
601        if self.flags.unicode {
602            flags.push('u');
603        }
604        if self.flags.extended {
605            flags.push('x');
606        }
607
608        fn url_encode(s: &str) -> String {
609            let mut out = String::with_capacity(s.len() * 3);
610            for b in s.bytes() {
611                match b {
612                    b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
613                        out.push(b as char);
614                    }
615                    _ => {
616                        out.push_str(&format!("%{b:02X}"));
617                    }
618                }
619            }
620            out
621        }
622
623        format!(
624            "https://regex101.com/?regex={}&testString={}&flags={}&flavor={}",
625            url_encode(pattern),
626            url_encode(test_string),
627            url_encode(&flags),
628            flavor,
629        )
630    }
631
632    /// Copy regex101 URL to clipboard.
633    pub fn copy_regex101_url(&mut self) {
634        let url = self.regex101_url();
635        match arboard::Clipboard::new() {
636            Ok(mut cb) => match cb.set_text(&url) {
637                Ok(()) => {
638                    self.set_status_message("regex101 URL copied to clipboard".to_string());
639                }
640                Err(e) => {
641                    self.set_status_message(format!("Clipboard error: {e}"));
642                }
643            },
644            Err(e) => {
645                self.set_status_message(format!("Clipboard error: {e}"));
646            }
647        }
648    }
649}
650
651/// Print the test string with matches highlighted using ANSI colors.
652fn print_colored_matches(text: &str, matches: &[engine::Match]) {
653    let mut pos = 0;
654    for m in matches {
655        if m.start > pos {
656            print!("{}", &text[pos..m.start]);
657        }
658        print!("\x1b[1;31m{}\x1b[0m", &text[m.start..m.end]);
659        pos = m.end;
660    }
661    if pos < text.len() {
662        print!("{}", &text[pos..]);
663    }
664    if !text.ends_with('\n') {
665        println!();
666    }
667}
668
669/// Print replacement output with replaced segments highlighted.
670fn print_colored_replace(output: &str, segments: &[engine::ReplaceSegment]) {
671    for seg in segments {
672        let chunk = &output[seg.start..seg.end];
673        if seg.is_replacement {
674            print!("\x1b[1;32m{chunk}\x1b[0m");
675        } else {
676            print!("{chunk}");
677        }
678    }
679    if !output.ends_with('\n') {
680        println!();
681    }
682}