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