Skip to main content

rgx/
app.rs

1use std::collections::VecDeque;
2use std::fmt::Write as _;
3use std::time::{Duration, Instant};
4
5use crate::ansi::{GREEN_BOLD, RED_BOLD, RESET};
6use crate::engine::{self, CompiledRegex, EngineFlags, EngineKind, RegexEngine};
7use crate::explain::{self, ExplainNode};
8use crate::input::editor::Editor;
9use crate::input::Action;
10
11const MAX_PATTERN_HISTORY: usize = 100;
12const STATUS_DISPLAY_TICKS: u32 = 40; // ~2 seconds at 50ms tick rate
13
14#[derive(Debug, Clone)]
15pub struct BenchmarkResult {
16    pub engine: EngineKind,
17    pub compile_time: Duration,
18    pub match_time: Duration,
19    pub match_count: usize,
20    pub error: Option<String>,
21}
22
23fn truncate(s: &str, max_chars: usize) -> String {
24    // Single pass: if `nth(max_chars)` yields a position, we have more than
25    // `max_chars` chars and `end` is the byte offset of the first char to
26    // drop. None means the string already fits.
27    match s.char_indices().nth(max_chars) {
28        Some((end, _)) => format!("{}...", &s[..end]),
29        None => s.to_string(),
30    }
31}
32
33#[derive(Default)]
34pub struct OverlayState {
35    pub help: bool,
36    pub help_page: usize,
37    pub recipes: bool,
38    pub recipe_index: usize,
39    pub benchmark: bool,
40    pub codegen: bool,
41    pub codegen_language_index: usize,
42    pub grex: Option<crate::ui::grex_overlay::GrexOverlayState>,
43}
44
45#[derive(Default)]
46pub struct ScrollState {
47    pub match_scroll: u16,
48    pub replace_scroll: u16,
49    pub explain_scroll: u16,
50}
51
52#[derive(Default)]
53pub struct PatternHistory {
54    pub entries: VecDeque<String>,
55    pub index: Option<usize>,
56    pub temp: Option<String>,
57}
58
59#[derive(Default)]
60pub struct MatchSelection {
61    pub match_index: usize,
62    pub capture_index: Option<usize>,
63}
64
65#[derive(Default)]
66pub struct StatusMessage {
67    pub text: Option<String>,
68    ticks: u32,
69}
70
71impl StatusMessage {
72    pub fn set(&mut self, message: String) {
73        self.text = Some(message);
74        self.ticks = STATUS_DISPLAY_TICKS;
75    }
76
77    pub fn tick(&mut self) -> bool {
78        if self.text.is_some() {
79            if self.ticks > 0 {
80                self.ticks -= 1;
81            } else {
82                self.text = None;
83                return true;
84            }
85        }
86        false
87    }
88}
89
90pub struct App {
91    pub regex_editor: Editor,
92    pub test_editor: Editor,
93    pub replace_editor: Editor,
94    pub focused_panel: u8,
95    pub engine_kind: EngineKind,
96    pub flags: EngineFlags,
97    pub matches: Vec<engine::Match>,
98    pub replace_result: Option<engine::ReplaceResult>,
99    pub explanation: Vec<ExplainNode>,
100    pub error: Option<String>,
101    pub overlay: OverlayState,
102    pub should_quit: bool,
103    pub scroll: ScrollState,
104    pub history: PatternHistory,
105    pub selection: MatchSelection,
106    pub status: StatusMessage,
107    pub show_whitespace: bool,
108    pub rounded_borders: bool,
109    pub vim_mode: bool,
110    pub vim_state: crate::input::vim::VimState,
111    pub compile_time: Option<Duration>,
112    pub match_time: Option<Duration>,
113    pub error_offset: Option<usize>,
114    pub output_on_quit: bool,
115    pub workspace_path: Option<String>,
116    pub benchmark_results: Vec<BenchmarkResult>,
117    pub syntax_tokens: Vec<crate::ui::syntax_highlight::SyntaxToken>,
118    #[cfg(feature = "pcre2-engine")]
119    pub debug_session: Option<crate::engine::pcre2_debug::DebugSession>,
120    #[cfg(feature = "pcre2-engine")]
121    debug_cache: Option<crate::engine::pcre2_debug::DebugSession>,
122    pub grex_result_tx: tokio::sync::mpsc::UnboundedSender<(u64, String)>,
123    grex_result_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, String)>,
124    engine: Box<dyn RegexEngine>,
125    compiled: Option<Box<dyn CompiledRegex>>,
126}
127
128impl App {
129    pub const PANEL_REGEX: u8 = 0;
130    pub const PANEL_TEST: u8 = 1;
131    pub const PANEL_REPLACE: u8 = 2;
132    pub const PANEL_MATCHES: u8 = 3;
133    pub const PANEL_EXPLAIN: u8 = 4;
134    pub const PANEL_COUNT: u8 = 5;
135}
136
137impl App {
138    pub fn new(engine_kind: EngineKind, flags: EngineFlags) -> Self {
139        let engine = engine::create_engine(engine_kind);
140        let (grex_result_tx, grex_result_rx) = tokio::sync::mpsc::unbounded_channel();
141        Self {
142            regex_editor: Editor::new(),
143            test_editor: Editor::new(),
144            replace_editor: Editor::new(),
145            focused_panel: 0,
146            engine_kind,
147            flags,
148            matches: Vec::new(),
149            replace_result: None,
150            explanation: Vec::new(),
151            error: None,
152            overlay: OverlayState::default(),
153            should_quit: false,
154            scroll: ScrollState::default(),
155            history: PatternHistory::default(),
156            selection: MatchSelection::default(),
157            status: StatusMessage::default(),
158            show_whitespace: false,
159            rounded_borders: false,
160            vim_mode: false,
161            vim_state: crate::input::vim::VimState::new(),
162            compile_time: None,
163            match_time: None,
164            error_offset: None,
165            output_on_quit: false,
166            workspace_path: None,
167            benchmark_results: Vec::new(),
168            syntax_tokens: Vec::new(),
169            #[cfg(feature = "pcre2-engine")]
170            debug_session: None,
171            #[cfg(feature = "pcre2-engine")]
172            debug_cache: None,
173            grex_result_tx,
174            grex_result_rx,
175            engine,
176            compiled: None,
177        }
178    }
179
180    pub fn set_replacement(&mut self, text: &str) {
181        self.replace_editor = Editor::with_content(text.to_string());
182        self.rereplace();
183    }
184
185    pub fn scroll_replace_up(&mut self) {
186        self.scroll.replace_scroll = self.scroll.replace_scroll.saturating_sub(1);
187    }
188
189    pub fn scroll_replace_down(&mut self) {
190        self.scroll.replace_scroll = self.scroll.replace_scroll.saturating_add(1);
191    }
192
193    pub fn rereplace(&mut self) {
194        let template = self.replace_editor.content().to_string();
195        if template.is_empty() || self.matches.is_empty() {
196            self.replace_result = None;
197            return;
198        }
199        let text = self.test_editor.content().to_string();
200        self.replace_result = Some(engine::replace_all(&text, &self.matches, &template));
201    }
202
203    pub fn set_pattern(&mut self, pattern: &str) {
204        self.regex_editor = Editor::with_content(pattern.to_string());
205        self.recompute();
206    }
207
208    pub fn set_test_string(&mut self, text: &str) {
209        self.test_editor = Editor::with_content(text.to_string());
210        self.rematch();
211    }
212
213    pub fn switch_engine(&mut self) {
214        self.engine_kind = self.engine_kind.next();
215        self.engine = engine::create_engine(self.engine_kind);
216        self.recompute();
217    }
218
219    /// Low-level engine setter. Does NOT call `recompute()` — the caller
220    /// must trigger recompilation separately if needed.
221    pub fn switch_engine_to(&mut self, kind: EngineKind) {
222        self.engine_kind = kind;
223        self.engine = engine::create_engine(kind);
224    }
225
226    pub fn scroll_match_up(&mut self) {
227        self.scroll.match_scroll = self.scroll.match_scroll.saturating_sub(1);
228    }
229
230    pub fn scroll_match_down(&mut self) {
231        self.scroll.match_scroll = self.scroll.match_scroll.saturating_add(1);
232    }
233
234    pub fn scroll_explain_up(&mut self) {
235        self.scroll.explain_scroll = self.scroll.explain_scroll.saturating_sub(1);
236    }
237
238    pub fn scroll_explain_down(&mut self) {
239        self.scroll.explain_scroll = self.scroll.explain_scroll.saturating_add(1);
240    }
241
242    pub fn recompute(&mut self) {
243        let pattern = self.regex_editor.content().to_string();
244        self.scroll.match_scroll = 0;
245        self.scroll.explain_scroll = 0;
246        self.error_offset = None;
247
248        if pattern.is_empty() {
249            self.compiled = None;
250            self.matches.clear();
251            self.explanation.clear();
252            self.error = None;
253            self.compile_time = None;
254            self.match_time = None;
255            self.syntax_tokens.clear();
256            return;
257        }
258
259        // Auto-select engine: upgrade (never downgrade) if the pattern
260        // requires a more powerful engine than the currently active one.
261        let suggested = engine::detect_minimum_engine(&pattern);
262        if engine::is_engine_upgrade(self.engine_kind, suggested) {
263            let prev = self.engine_kind;
264            self.engine_kind = suggested;
265            self.engine = engine::create_engine(suggested);
266            self.status.set(format!(
267                "Auto-switched {prev} \u{2192} {suggested} for this pattern",
268            ));
269        }
270
271        // Compile
272        let compile_start = Instant::now();
273        match self.engine.compile(&pattern, &self.flags) {
274            Ok(compiled) => {
275                self.compile_time = Some(compile_start.elapsed());
276                self.compiled = Some(compiled);
277                self.error = None;
278            }
279            Err(e) => {
280                self.compile_time = Some(compile_start.elapsed());
281                self.compiled = None;
282                self.matches.clear();
283                self.error = Some(e.to_string());
284            }
285        }
286
287        // Rebuild syntax highlight tokens (pattern changed)
288        self.syntax_tokens = crate::ui::syntax_highlight::highlight(&pattern);
289
290        // Explain (uses regex-syntax, independent of engine). regex-syntax
291        // can't parse fancy-regex-only or PCRE2-only features (lookaround,
292        // backrefs, recursion, etc.), so failure here is common and expected
293        // for patterns the engine compiled successfully. Only surface the
294        // explain error when the engine itself failed to compile — otherwise
295        // just leave the explanation pane blank. Previously this path wrote
296        // the regex-syntax error into `self.error` even on a successful
297        // compile, which propagated into `-p` batch mode and made it reject
298        // every valid lookaround pattern with a misleading "not supported"
299        // message.
300        match explain::explain(&pattern) {
301            Ok(nodes) => self.explanation = nodes,
302            Err((msg, offset)) => {
303                self.explanation.clear();
304                if self.error.is_some() {
305                    // Engine also failed: keep its error but also capture
306                    // the explain offset for the UI pattern-highlight pointer.
307                    if self.error_offset.is_none() {
308                        self.error_offset = offset;
309                    }
310                } else {
311                    // Engine compiled fine; regex-syntax just can't explain
312                    // this pattern's extended features. Record the reason
313                    // for future UI surfacing but don't treat it as a
314                    // compile error.
315                    let _ = msg;
316                    let _ = offset;
317                }
318            }
319        }
320
321        // Match
322        self.rematch();
323    }
324
325    pub fn rematch(&mut self) {
326        self.scroll.match_scroll = 0;
327        self.selection.match_index = 0;
328        self.selection.capture_index = None;
329        if let Some(compiled) = &self.compiled {
330            let text = self.test_editor.content().to_string();
331            if text.is_empty() {
332                self.matches.clear();
333                self.replace_result = None;
334                self.match_time = None;
335                return;
336            }
337            let match_start = Instant::now();
338            match compiled.find_matches(&text) {
339                Ok(m) => {
340                    self.match_time = Some(match_start.elapsed());
341                    self.matches = m;
342                }
343                Err(e) => {
344                    self.match_time = Some(match_start.elapsed());
345                    self.matches.clear();
346                    self.error = Some(e.to_string());
347                }
348            }
349        } else {
350            self.matches.clear();
351            self.match_time = None;
352        }
353        self.rereplace();
354    }
355
356    // --- Pattern history ---
357
358    pub fn commit_pattern_to_history(&mut self) {
359        let pattern = self.regex_editor.content().to_string();
360        if pattern.is_empty() {
361            return;
362        }
363        if self.history.entries.back().map(String::as_str) == Some(&pattern) {
364            return;
365        }
366        self.history.entries.push_back(pattern);
367        if self.history.entries.len() > MAX_PATTERN_HISTORY {
368            self.history.entries.pop_front();
369        }
370        self.history.index = None;
371        self.history.temp = None;
372    }
373
374    pub fn history_prev(&mut self) {
375        if self.history.entries.is_empty() {
376            return;
377        }
378        let new_index = match self.history.index {
379            Some(0) => return,
380            Some(idx) => idx - 1,
381            None => {
382                self.history.temp = Some(self.regex_editor.content().to_string());
383                self.history.entries.len() - 1
384            }
385        };
386        self.history.index = Some(new_index);
387        let pattern = self.history.entries[new_index].clone();
388        self.regex_editor = Editor::with_content(pattern);
389        self.recompute();
390    }
391
392    pub fn history_next(&mut self) {
393        let Some(idx) = self.history.index else {
394            return;
395        };
396        let new_content = if idx + 1 < self.history.entries.len() {
397            let new_index = idx + 1;
398            self.history.index = Some(new_index);
399            self.history.entries[new_index].clone()
400        } else {
401            // Past end — restore temp
402            self.history.index = None;
403            self.history.temp.take().unwrap_or_default()
404        };
405        self.regex_editor = Editor::with_content(new_content);
406        self.recompute();
407    }
408
409    // --- Match selection + clipboard ---
410
411    pub fn select_match_next(&mut self) {
412        if self.matches.is_empty() {
413            return;
414        }
415        match self.selection.capture_index {
416            None => {
417                let m = &self.matches[self.selection.match_index];
418                if !m.captures.is_empty() {
419                    self.selection.capture_index = Some(0);
420                } else if self.selection.match_index + 1 < self.matches.len() {
421                    self.selection.match_index += 1;
422                }
423            }
424            Some(ci) => {
425                let m = &self.matches[self.selection.match_index];
426                if ci + 1 < m.captures.len() {
427                    self.selection.capture_index = Some(ci + 1);
428                } else if self.selection.match_index + 1 < self.matches.len() {
429                    self.selection.match_index += 1;
430                    self.selection.capture_index = None;
431                }
432            }
433        }
434        self.scroll_to_selected();
435    }
436
437    pub fn select_match_prev(&mut self) {
438        if self.matches.is_empty() {
439            return;
440        }
441        match self.selection.capture_index {
442            Some(0) => {
443                self.selection.capture_index = None;
444            }
445            Some(ci) => {
446                self.selection.capture_index = Some(ci - 1);
447            }
448            None => {
449                if self.selection.match_index > 0 {
450                    self.selection.match_index -= 1;
451                    let m = &self.matches[self.selection.match_index];
452                    if !m.captures.is_empty() {
453                        self.selection.capture_index = Some(m.captures.len() - 1);
454                    }
455                }
456            }
457        }
458        self.scroll_to_selected();
459    }
460
461    fn scroll_to_selected(&mut self) {
462        if self.matches.is_empty() || self.selection.match_index >= self.matches.len() {
463            return;
464        }
465        let mut line = 0usize;
466        for i in 0..self.selection.match_index {
467            line += 1 + self.matches[i].captures.len();
468        }
469        if let Some(ci) = self.selection.capture_index {
470            line += 1 + ci;
471        }
472        self.scroll.match_scroll = u16::try_from(line).unwrap_or(u16::MAX);
473    }
474
475    pub fn copy_selected_match(&mut self) {
476        let text = self.selected_text();
477        let Some(text) = text else { return };
478        let msg = format!("Copied: \"{}\"", truncate(&text, 40));
479        self.copy_to_clipboard(&text, &msg);
480    }
481
482    pub fn copy_pattern(&mut self) {
483        let pattern = self.regex_editor.content().to_string();
484        if pattern.is_empty() {
485            return;
486        }
487        let msg = format!("Copied pattern: \"{}\"", truncate(&pattern, 40));
488        self.copy_to_clipboard(&pattern, &msg);
489    }
490
491    fn copy_to_clipboard(&mut self, text: &str, success_msg: &str) {
492        match arboard::Clipboard::new() {
493            Ok(mut cb) => match cb.set_text(text) {
494                Ok(()) => self.status.set(success_msg.to_string()),
495                Err(e) => self.status.set(format!("Clipboard error: {e}")),
496            },
497            Err(e) => self.status.set(format!("Clipboard error: {e}")),
498        }
499    }
500
501    /// Print match results or replacement output to stdout.
502    pub fn print_output(&self, group: Option<&str>, count: bool, color: bool) {
503        if count {
504            println!("{}", self.matches.len());
505            return;
506        }
507        if let Some(ref result) = self.replace_result {
508            if color {
509                print_colored_replace(&result.output, &result.segments);
510            } else {
511                print!("{}", result.output);
512            }
513        } else if let Some(group_spec) = group {
514            for m in &self.matches {
515                if let Some(text) = engine::lookup_capture(m, group_spec) {
516                    if color {
517                        println!("{RED_BOLD}{text}{RESET}");
518                    } else {
519                        println!("{text}");
520                    }
521                } else {
522                    eprintln!("rgx: group '{group_spec}' not found in match");
523                }
524            }
525        } else if color {
526            let text = self.test_editor.content();
527            print_colored_matches(text, &self.matches);
528        } else {
529            for m in &self.matches {
530                println!("{}", m.text);
531            }
532        }
533    }
534
535    /// Print matches as structured JSON.
536    pub fn print_json_output(&self) {
537        println!(
538            "{}",
539            serde_json::to_string_pretty(&self.matches).unwrap_or_else(|_| "[]".to_string())
540        );
541    }
542
543    fn selected_text(&self) -> Option<String> {
544        let m = self.matches.get(self.selection.match_index)?;
545        match self.selection.capture_index {
546            None => Some(m.text.clone()),
547            Some(ci) => m.captures.get(ci).map(|c| c.text.clone()),
548        }
549    }
550
551    /// Apply a mutating editor operation to the currently focused editor panel,
552    /// then trigger the appropriate recompute/rematch/rereplace.
553    pub fn edit_focused(&mut self, f: impl FnOnce(&mut Editor)) {
554        match self.focused_panel {
555            Self::PANEL_REGEX => {
556                f(&mut self.regex_editor);
557                self.recompute();
558            }
559            Self::PANEL_TEST => {
560                f(&mut self.test_editor);
561                self.rematch();
562            }
563            Self::PANEL_REPLACE => {
564                f(&mut self.replace_editor);
565                self.rereplace();
566            }
567            _ => {}
568        }
569    }
570
571    /// Apply a non-mutating cursor movement to the currently focused editor panel.
572    pub fn move_focused(&mut self, f: impl FnOnce(&mut Editor)) {
573        match self.focused_panel {
574            Self::PANEL_REGEX => f(&mut self.regex_editor),
575            Self::PANEL_TEST => f(&mut self.test_editor),
576            Self::PANEL_REPLACE => f(&mut self.replace_editor),
577            _ => {}
578        }
579    }
580
581    pub fn run_benchmark(&mut self) {
582        let pattern = self.regex_editor.content().to_string();
583        let text = self.test_editor.content().to_string();
584        if pattern.is_empty() || text.is_empty() {
585            return;
586        }
587
588        let mut results = Vec::new();
589        for kind in EngineKind::all() {
590            let eng = engine::create_engine(kind);
591            let compile_start = Instant::now();
592            let compiled = match eng.compile(&pattern, &self.flags) {
593                Ok(c) => c,
594                Err(e) => {
595                    results.push(BenchmarkResult {
596                        engine: kind,
597                        compile_time: compile_start.elapsed(),
598                        match_time: Duration::ZERO,
599                        match_count: 0,
600                        error: Some(e.to_string()),
601                    });
602                    continue;
603                }
604            };
605            let compile_time = compile_start.elapsed();
606            let match_start = Instant::now();
607            let (match_count, error) = match compiled.find_matches(&text) {
608                Ok(matches) => (matches.len(), None),
609                Err(e) => (0, Some(e.to_string())),
610            };
611            results.push(BenchmarkResult {
612                engine: kind,
613                compile_time,
614                match_time: match_start.elapsed(),
615                match_count,
616                error,
617            });
618        }
619        self.benchmark_results = results;
620        self.overlay.benchmark = true;
621    }
622
623    /// Generate a regex101.com URL from the current state.
624    pub fn regex101_url(&self) -> String {
625        let pattern = self.regex_editor.content();
626        let test_string = self.test_editor.content();
627
628        let flavor = match self.engine_kind {
629            #[cfg(feature = "pcre2-engine")]
630            EngineKind::Pcre2 => "pcre2",
631            _ => "ecmascript",
632        };
633
634        let mut flags = String::from("g");
635        if self.flags.case_insensitive {
636            flags.push('i');
637        }
638        if self.flags.multi_line {
639            flags.push('m');
640        }
641        if self.flags.dot_matches_newline {
642            flags.push('s');
643        }
644        if self.flags.unicode {
645            flags.push('u');
646        }
647        if self.flags.extended {
648            flags.push('x');
649        }
650
651        format!(
652            "https://regex101.com/?regex={}&testString={}&flags={}&flavor={}",
653            url_encode(pattern),
654            url_encode(test_string),
655            url_encode(&flags),
656            flavor,
657        )
658    }
659
660    /// Copy regex101 URL to clipboard.
661    pub fn copy_regex101_url(&mut self) {
662        let url = self.regex101_url();
663        self.copy_to_clipboard(&url, "regex101 URL copied to clipboard");
664    }
665
666    /// Generate code for the current pattern in the given language and copy to clipboard.
667    pub fn generate_code(&mut self, lang: &crate::codegen::Language) {
668        let pattern = self.regex_editor.content().to_string();
669        if pattern.is_empty() {
670            self.status
671                .set("No pattern to generate code for".to_string());
672            return;
673        }
674        let code = crate::codegen::generate_code(lang, &pattern, &self.flags);
675        self.copy_to_clipboard(&code, &format!("{lang} code copied to clipboard"));
676        self.overlay.codegen = false;
677    }
678
679    #[cfg(feature = "pcre2-engine")]
680    pub fn start_debug(&mut self, max_steps: usize) {
681        use crate::engine::pcre2_debug::{self, DebugSession};
682
683        let pattern = self.regex_editor.content().to_string();
684        let subject = self.test_editor.content().to_string();
685        if pattern.is_empty() || subject.is_empty() {
686            self.status
687                .set("Debugger needs both a pattern and test string".to_string());
688            return;
689        }
690
691        if self.engine_kind != EngineKind::Pcre2 {
692            self.switch_engine_to(EngineKind::Pcre2);
693            self.recompute();
694        }
695
696        // Restore cached session if pattern and subject haven't changed,
697        // preserving the user's step position and heatmap toggle.
698        if let Some(ref cached) = self.debug_cache {
699            if cached.pattern == pattern && cached.subject == subject {
700                self.debug_session = self.debug_cache.take();
701                return;
702            }
703        }
704
705        let start_offset = self.selected_match_start();
706
707        match pcre2_debug::debug_match(&pattern, &subject, &self.flags, max_steps, start_offset) {
708            Ok(trace) => {
709                self.debug_session = Some(DebugSession {
710                    trace,
711                    step: 0,
712                    show_heatmap: false,
713                    pattern,
714                    subject,
715                });
716            }
717            Err(e) => {
718                self.status.set(format!("Debugger error: {e}"));
719            }
720        }
721    }
722
723    #[cfg(not(feature = "pcre2-engine"))]
724    pub fn start_debug(&mut self, _max_steps: usize) {
725        self.status
726            .set("Debugger requires PCRE2 (build with --features pcre2-engine)".to_string());
727    }
728
729    #[cfg(feature = "pcre2-engine")]
730    fn selected_match_start(&self) -> usize {
731        if !self.matches.is_empty() && self.selection.match_index < self.matches.len() {
732            self.matches[self.selection.match_index].start
733        } else {
734            0
735        }
736    }
737
738    #[cfg(feature = "pcre2-engine")]
739    pub fn close_debug(&mut self) {
740        self.debug_cache = self.debug_session.take();
741    }
742
743    pub fn debug_step_forward(&mut self) {
744        #[cfg(feature = "pcre2-engine")]
745        if let Some(ref mut s) = self.debug_session {
746            if s.step + 1 < s.trace.steps.len() {
747                s.step += 1;
748            }
749        }
750    }
751
752    pub fn debug_step_back(&mut self) {
753        #[cfg(feature = "pcre2-engine")]
754        if let Some(ref mut s) = self.debug_session {
755            s.step = s.step.saturating_sub(1);
756        }
757    }
758
759    pub fn debug_jump_start(&mut self) {
760        #[cfg(feature = "pcre2-engine")]
761        if let Some(ref mut s) = self.debug_session {
762            s.step = 0;
763        }
764    }
765
766    pub fn debug_jump_end(&mut self) {
767        #[cfg(feature = "pcre2-engine")]
768        if let Some(ref mut s) = self.debug_session {
769            if !s.trace.steps.is_empty() {
770                s.step = s.trace.steps.len() - 1;
771            }
772        }
773    }
774
775    pub fn debug_next_match(&mut self) {
776        #[cfg(feature = "pcre2-engine")]
777        if let Some(ref mut s) = self.debug_session {
778            let current_attempt = s.trace.steps.get(s.step).map_or(0, |st| st.match_attempt);
779            for (i, step) in s.trace.steps.iter().enumerate().skip(s.step + 1) {
780                if step.match_attempt > current_attempt {
781                    s.step = i;
782                    return;
783                }
784            }
785        }
786    }
787
788    pub fn debug_next_backtrack(&mut self) {
789        #[cfg(feature = "pcre2-engine")]
790        if let Some(ref mut s) = self.debug_session {
791            for (i, step) in s.trace.steps.iter().enumerate().skip(s.step + 1) {
792                if step.is_backtrack {
793                    s.step = i;
794                    return;
795                }
796            }
797        }
798    }
799
800    pub fn debug_toggle_heatmap(&mut self) {
801        #[cfg(feature = "pcre2-engine")]
802        if let Some(ref mut s) = self.debug_session {
803            s.show_heatmap = !s.show_heatmap;
804        }
805    }
806
807    pub fn handle_action(&mut self, action: Action, debug_max_steps: usize) {
808        match action {
809            Action::Quit => {
810                self.should_quit = true;
811            }
812            Action::OutputAndQuit => {
813                self.output_on_quit = true;
814                self.should_quit = true;
815            }
816            Action::SwitchPanel => {
817                if self.focused_panel == Self::PANEL_REGEX {
818                    self.commit_pattern_to_history();
819                }
820                self.focused_panel = (self.focused_panel + 1) % Self::PANEL_COUNT;
821            }
822            Action::SwitchPanelBack => {
823                if self.focused_panel == Self::PANEL_REGEX {
824                    self.commit_pattern_to_history();
825                }
826                self.focused_panel =
827                    (self.focused_panel + Self::PANEL_COUNT - 1) % Self::PANEL_COUNT;
828            }
829            Action::SwitchEngine => {
830                self.switch_engine();
831            }
832            Action::Undo => {
833                if self.focused_panel == Self::PANEL_REGEX && self.regex_editor.undo() {
834                    self.recompute();
835                } else if self.focused_panel == Self::PANEL_TEST && self.test_editor.undo() {
836                    self.rematch();
837                } else if self.focused_panel == Self::PANEL_REPLACE && self.replace_editor.undo() {
838                    self.rereplace();
839                }
840            }
841            Action::Redo => {
842                if self.focused_panel == Self::PANEL_REGEX && self.regex_editor.redo() {
843                    self.recompute();
844                } else if self.focused_panel == Self::PANEL_TEST && self.test_editor.redo() {
845                    self.rematch();
846                } else if self.focused_panel == Self::PANEL_REPLACE && self.replace_editor.redo() {
847                    self.rereplace();
848                }
849            }
850            Action::HistoryPrev => {
851                if self.focused_panel == Self::PANEL_REGEX {
852                    self.history_prev();
853                }
854            }
855            Action::HistoryNext => {
856                if self.focused_panel == Self::PANEL_REGEX {
857                    self.history_next();
858                }
859            }
860            Action::CopyMatch => {
861                if self.focused_panel == Self::PANEL_REGEX {
862                    self.copy_pattern();
863                } else if self.focused_panel == Self::PANEL_MATCHES {
864                    self.copy_selected_match();
865                }
866            }
867            Action::ToggleWhitespace => {
868                self.show_whitespace = !self.show_whitespace;
869            }
870            Action::ToggleCaseInsensitive => {
871                self.flags.toggle_case_insensitive();
872                self.recompute();
873            }
874            Action::ToggleMultiLine => {
875                self.flags.toggle_multi_line();
876                self.recompute();
877            }
878            Action::ToggleDotAll => {
879                self.flags.toggle_dot_matches_newline();
880                self.recompute();
881            }
882            Action::ToggleUnicode => {
883                self.flags.toggle_unicode();
884                self.recompute();
885            }
886            Action::ToggleExtended => {
887                self.flags.toggle_extended();
888                self.recompute();
889            }
890            Action::ShowHelp => {
891                self.overlay.help = true;
892            }
893            Action::OpenRecipes => {
894                self.overlay.recipes = true;
895                self.overlay.recipe_index = 0;
896            }
897            Action::OpenGrex => {
898                self.overlay.grex = Some(crate::ui::grex_overlay::GrexOverlayState::default());
899            }
900            Action::Benchmark => {
901                self.run_benchmark();
902            }
903            Action::ExportRegex101 => {
904                self.copy_regex101_url();
905            }
906            Action::GenerateCode => {
907                self.overlay.codegen = true;
908                self.overlay.codegen_language_index = 0;
909            }
910            Action::InsertChar(c) => self.edit_focused(|ed| ed.insert_char(c)),
911            Action::InsertNewline => {
912                if self.focused_panel == Self::PANEL_TEST {
913                    self.test_editor.insert_newline();
914                    self.rematch();
915                }
916            }
917            Action::DeleteBack => self.edit_focused(Editor::delete_back),
918            Action::DeleteForward => self.edit_focused(Editor::delete_forward),
919            Action::MoveCursorLeft => self.move_focused(Editor::move_left),
920            Action::MoveCursorRight => self.move_focused(Editor::move_right),
921            Action::MoveCursorWordLeft => self.move_focused(Editor::move_word_left),
922            Action::MoveCursorWordRight => self.move_focused(Editor::move_word_right),
923            Action::ScrollUp => match self.focused_panel {
924                Self::PANEL_TEST => self.test_editor.move_up(),
925                Self::PANEL_MATCHES => self.select_match_prev(),
926                Self::PANEL_EXPLAIN => self.scroll_explain_up(),
927                _ => {}
928            },
929            Action::ScrollDown => match self.focused_panel {
930                Self::PANEL_TEST => self.test_editor.move_down(),
931                Self::PANEL_MATCHES => self.select_match_next(),
932                Self::PANEL_EXPLAIN => self.scroll_explain_down(),
933                _ => {}
934            },
935            Action::MoveCursorHome => self.move_focused(Editor::move_home),
936            Action::MoveCursorEnd => self.move_focused(Editor::move_end),
937            Action::DeleteCharAtCursor => self.edit_focused(Editor::delete_char_at_cursor),
938            Action::DeleteLine => self.edit_focused(Editor::delete_line),
939            Action::ChangeLine => self.edit_focused(Editor::clear_line),
940            Action::OpenLineBelow => {
941                if self.focused_panel == Self::PANEL_TEST {
942                    self.test_editor.open_line_below();
943                    self.rematch();
944                } else {
945                    self.vim_state.cancel_insert();
946                }
947            }
948            Action::OpenLineAbove => {
949                if self.focused_panel == Self::PANEL_TEST {
950                    self.test_editor.open_line_above();
951                    self.rematch();
952                } else {
953                    self.vim_state.cancel_insert();
954                }
955            }
956            Action::MoveToFirstNonBlank => self.move_focused(Editor::move_to_first_non_blank),
957            Action::MoveToFirstLine => self.move_focused(Editor::move_to_first_line),
958            Action::MoveToLastLine => self.move_focused(Editor::move_to_last_line),
959            Action::MoveCursorWordForwardEnd => self.move_focused(Editor::move_word_forward_end),
960            Action::EnterInsertMode => {}
961            Action::EnterInsertModeAppend => self.move_focused(Editor::move_right),
962            Action::EnterInsertModeLineStart => self.move_focused(Editor::move_to_first_non_blank),
963            Action::EnterInsertModeLineEnd => self.move_focused(Editor::move_end),
964            Action::EnterNormalMode => self.move_focused(Editor::move_left_in_line),
965            Action::PasteClipboard => {
966                if let Ok(mut cb) = arboard::Clipboard::new() {
967                    if let Ok(text) = cb.get_text() {
968                        self.edit_focused(|ed| ed.insert_str(&text));
969                    }
970                }
971            }
972            Action::ToggleDebugger => {
973                #[cfg(feature = "pcre2-engine")]
974                if self.debug_session.is_some() {
975                    self.close_debug();
976                } else {
977                    self.start_debug(debug_max_steps);
978                }
979                #[cfg(not(feature = "pcre2-engine"))]
980                self.start_debug(debug_max_steps);
981            }
982            Action::SaveWorkspace | Action::None => {}
983        }
984    }
985
986    /// If the grex overlay has a pending debounce deadline that has passed, spawn a
987    /// blocking task to regenerate the pattern with the current options. Results are
988    /// delivered via `grex_result_tx` and claimed later by `drain_grex_results`.
989    pub fn maybe_run_grex_generation(&mut self) {
990        let Some(overlay) = self.overlay.grex.as_mut() else {
991            return;
992        };
993        let Some(deadline) = overlay.debounce_deadline else {
994            return;
995        };
996        if std::time::Instant::now() < deadline {
997            return;
998        }
999        overlay.debounce_deadline = None;
1000        overlay.generation_counter += 1;
1001        let counter = overlay.generation_counter;
1002        let examples: Vec<String> = overlay
1003            .editor
1004            .content()
1005            .lines()
1006            .filter(|l| !l.is_empty())
1007            .map(ToString::to_string)
1008            .collect();
1009        let options = overlay.options;
1010        let tx = self.grex_result_tx.clone();
1011
1012        tokio::task::spawn_blocking(move || {
1013            let pattern = crate::grex_integration::generate(&examples, options);
1014            let _ = tx.send((counter, pattern));
1015        });
1016    }
1017
1018    /// Drain any grex generation results that arrived since the last tick, applying
1019    /// only the result that matches the current generation counter.
1020    pub fn drain_grex_results(&mut self) {
1021        while let Ok((counter, pattern)) = self.grex_result_rx.try_recv() {
1022            if let Some(overlay) = self.overlay.grex.as_mut() {
1023                if counter == overlay.generation_counter {
1024                    overlay.generated_pattern = Some(pattern);
1025                }
1026            }
1027        }
1028    }
1029
1030    /// Dispatch a key event to the grex overlay. Returns true if the key was consumed.
1031    /// Caller should only invoke this when `self.overlay.grex.is_some()`.
1032    pub fn dispatch_grex_overlay_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
1033        use crossterm::event::{KeyCode, KeyModifiers};
1034        const DEBOUNCE_MS: u64 = 150;
1035        let debounce = std::time::Duration::from_millis(DEBOUNCE_MS);
1036
1037        let Some(overlay) = self.overlay.grex.as_mut() else {
1038            return false;
1039        };
1040
1041        // Accept / cancel first — these take precedence regardless of other modifiers.
1042        match key.code {
1043            KeyCode::Esc => {
1044                self.overlay.grex = None;
1045                return true;
1046            }
1047            KeyCode::Tab => {
1048                let pattern = overlay
1049                    .generated_pattern
1050                    .as_deref()
1051                    .filter(|p| !p.is_empty())
1052                    .map(str::to_string);
1053                if let Some(pattern) = pattern {
1054                    self.set_pattern(&pattern);
1055                    self.overlay.grex = None;
1056                }
1057                return true;
1058            }
1059            _ => {}
1060        }
1061
1062        // Flag toggles (Alt+d/a/c). These reset the debounce so the new flags regenerate.
1063        if key.modifiers.contains(KeyModifiers::ALT) {
1064            match key.code {
1065                KeyCode::Char('d') => {
1066                    overlay.options.digit = !overlay.options.digit;
1067                    overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1068                    return true;
1069                }
1070                KeyCode::Char('a') => {
1071                    overlay.options.anchors = !overlay.options.anchors;
1072                    overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1073                    return true;
1074                }
1075                KeyCode::Char('c') => {
1076                    overlay.options.case_insensitive = !overlay.options.case_insensitive;
1077                    overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1078                    return true;
1079                }
1080                _ => {}
1081            }
1082        }
1083
1084        // Editor input — dispatch a focused set of keys to the overlay editor.
1085        let mut consumed = true;
1086        match key.code {
1087            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
1088                overlay.editor.insert_char(c);
1089            }
1090            KeyCode::Enter => overlay.editor.insert_newline(),
1091            KeyCode::Backspace => overlay.editor.delete_back(),
1092            KeyCode::Delete => overlay.editor.delete_forward(),
1093            KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => {
1094                overlay.editor.move_word_left();
1095            }
1096            KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => {
1097                overlay.editor.move_word_right();
1098            }
1099            KeyCode::Left => overlay.editor.move_left(),
1100            KeyCode::Right => overlay.editor.move_right(),
1101            KeyCode::Up => overlay.editor.move_up(),
1102            KeyCode::Down => overlay.editor.move_down(),
1103            KeyCode::Home => overlay.editor.move_home(),
1104            KeyCode::End => overlay.editor.move_end(),
1105            _ => consumed = false,
1106        }
1107
1108        if consumed {
1109            overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1110        }
1111        consumed
1112    }
1113}
1114
1115fn url_encode(s: &str) -> String {
1116    let mut out = String::with_capacity(s.len() * 3);
1117    for b in s.bytes() {
1118        match b {
1119            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1120                out.push(b as char);
1121            }
1122            _ => {
1123                let _ = write!(out, "%{b:02X}");
1124            }
1125        }
1126    }
1127    out
1128}
1129
1130fn print_colored_matches(text: &str, matches: &[engine::Match]) {
1131    let mut pos = 0;
1132    for m in matches {
1133        if m.start > pos {
1134            print!("{}", &text[pos..m.start]);
1135        }
1136        print!("{RED_BOLD}{}{RESET}", &text[m.start..m.end]);
1137        pos = m.end;
1138    }
1139    if pos < text.len() {
1140        print!("{}", &text[pos..]);
1141    }
1142    if !text.ends_with('\n') {
1143        println!();
1144    }
1145}
1146
1147/// Print replacement output with replaced segments highlighted.
1148fn print_colored_replace(output: &str, segments: &[engine::ReplaceSegment]) {
1149    for seg in segments {
1150        let chunk = &output[seg.start..seg.end];
1151        if seg.is_replacement {
1152            print!("{GREEN_BOLD}{chunk}{RESET}");
1153        } else {
1154            print!("{chunk}");
1155        }
1156    }
1157    if !output.ends_with('\n') {
1158        println!();
1159    }
1160}