Skip to main content

rgx/
app.rs

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