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