Skip to main content

rgx/
app.rs

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