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