Skip to main content

rgx/
app.rs

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