1use std::collections::VecDeque;
2use std::time::{Duration, Instant};
3
4use crate::ansi::{GREEN_BOLD, RED_BOLD, RESET};
5use crate::engine::{self, CompiledRegex, EngineFlags, EngineKind, RegexEngine};
6use crate::explain::{self, ExplainNode};
7use crate::input::editor::Editor;
8
9const MAX_PATTERN_HISTORY: usize = 100;
10const STATUS_DISPLAY_TICKS: u32 = 40; #[derive(Debug, Clone)]
13pub struct BenchmarkResult {
14 pub engine: EngineKind,
15 pub compile_time: Duration,
16 pub match_time: Duration,
17 pub match_count: usize,
18 pub error: Option<String>,
19}
20
21fn truncate(s: &str, max_chars: usize) -> String {
22 let char_count = s.chars().count();
23 if char_count <= max_chars {
24 s.to_string()
25 } else {
26 let end = s
27 .char_indices()
28 .nth(max_chars)
29 .map(|(i, _)| i)
30 .unwrap_or(s.len());
31 format!("{}...", &s[..end])
32 }
33}
34
35pub struct App {
36 pub regex_editor: Editor,
37 pub test_editor: Editor,
38 pub replace_editor: Editor,
39 pub focused_panel: u8,
40 pub engine_kind: EngineKind,
41 pub flags: EngineFlags,
42 pub matches: Vec<engine::Match>,
43 pub replace_result: Option<engine::ReplaceResult>,
44 pub explanation: Vec<ExplainNode>,
45 pub error: Option<String>,
46 pub show_help: bool,
47 pub help_page: usize,
48 pub should_quit: bool,
49 pub match_scroll: u16,
50 pub replace_scroll: u16,
51 pub explain_scroll: u16,
52 pub pattern_history: VecDeque<String>,
54 pub history_index: Option<usize>,
55 history_temp: Option<String>,
56 pub selected_match: usize,
58 pub selected_capture: Option<usize>,
59 pub clipboard_status: Option<String>,
60 clipboard_status_ticks: u32,
61 pub show_whitespace: bool,
62 pub rounded_borders: bool,
63 pub vim_mode: bool,
64 pub vim_state: crate::input::vim::VimState,
65 pub compile_time: Option<Duration>,
66 pub match_time: Option<Duration>,
67 pub error_offset: Option<usize>,
68 pub output_on_quit: bool,
69 pub workspace_path: Option<String>,
70 pub show_recipes: bool,
71 pub recipe_index: usize,
72 pub show_benchmark: bool,
73 pub benchmark_results: Vec<BenchmarkResult>,
74 pub show_codegen: bool,
75 pub codegen_language_index: usize,
76 #[cfg(feature = "pcre2-engine")]
77 pub debug_session: Option<crate::engine::pcre2_debug::DebugSession>,
78 #[cfg(feature = "pcre2-engine")]
79 debug_cache: Option<crate::engine::pcre2_debug::DebugSession>,
80 engine: Box<dyn RegexEngine>,
81 compiled: Option<Box<dyn CompiledRegex>>,
82}
83
84impl App {
85 pub const PANEL_REGEX: u8 = 0;
86 pub const PANEL_TEST: u8 = 1;
87 pub const PANEL_REPLACE: u8 = 2;
88 pub const PANEL_MATCHES: u8 = 3;
89 pub const PANEL_EXPLAIN: u8 = 4;
90 pub const PANEL_COUNT: u8 = 5;
91}
92
93impl App {
94 pub fn new(engine_kind: EngineKind, flags: EngineFlags) -> Self {
95 let engine = engine::create_engine(engine_kind);
96 Self {
97 regex_editor: Editor::new(),
98 test_editor: Editor::new(),
99 replace_editor: Editor::new(),
100 focused_panel: 0,
101 engine_kind,
102 flags,
103 matches: Vec::new(),
104 replace_result: None,
105 explanation: Vec::new(),
106 error: None,
107 show_help: false,
108 help_page: 0,
109 should_quit: false,
110 match_scroll: 0,
111 replace_scroll: 0,
112 explain_scroll: 0,
113 pattern_history: VecDeque::new(),
114 history_index: None,
115 history_temp: None,
116 selected_match: 0,
117 selected_capture: None,
118 clipboard_status: None,
119 clipboard_status_ticks: 0,
120 show_whitespace: false,
121 rounded_borders: false,
122 vim_mode: false,
123 vim_state: crate::input::vim::VimState::new(),
124 compile_time: None,
125 match_time: None,
126 error_offset: None,
127 output_on_quit: false,
128 workspace_path: None,
129 show_recipes: false,
130 recipe_index: 0,
131 show_benchmark: false,
132 benchmark_results: Vec::new(),
133 show_codegen: false,
134 codegen_language_index: 0,
135 #[cfg(feature = "pcre2-engine")]
136 debug_session: None,
137 #[cfg(feature = "pcre2-engine")]
138 debug_cache: None,
139 engine,
140 compiled: None,
141 }
142 }
143
144 pub fn set_replacement(&mut self, text: &str) {
145 self.replace_editor = Editor::with_content(text.to_string());
146 self.rereplace();
147 }
148
149 pub fn scroll_replace_up(&mut self) {
150 self.replace_scroll = self.replace_scroll.saturating_sub(1);
151 }
152
153 pub fn scroll_replace_down(&mut self) {
154 self.replace_scroll = self.replace_scroll.saturating_add(1);
155 }
156
157 pub fn rereplace(&mut self) {
158 let template = self.replace_editor.content().to_string();
159 if template.is_empty() || self.matches.is_empty() {
160 self.replace_result = None;
161 return;
162 }
163 let text = self.test_editor.content().to_string();
164 self.replace_result = Some(engine::replace_all(&text, &self.matches, &template));
165 }
166
167 pub fn set_pattern(&mut self, pattern: &str) {
168 self.regex_editor = Editor::with_content(pattern.to_string());
169 self.recompute();
170 }
171
172 pub fn set_test_string(&mut self, text: &str) {
173 self.test_editor = Editor::with_content(text.to_string());
174 self.rematch();
175 }
176
177 pub fn switch_engine(&mut self) {
178 self.engine_kind = self.engine_kind.next();
179 self.engine = engine::create_engine(self.engine_kind);
180 self.recompute();
181 }
182
183 pub fn switch_engine_to(&mut self, kind: EngineKind) {
186 self.engine_kind = kind;
187 self.engine = engine::create_engine(kind);
188 }
189
190 pub fn scroll_match_up(&mut self) {
191 self.match_scroll = self.match_scroll.saturating_sub(1);
192 }
193
194 pub fn scroll_match_down(&mut self) {
195 self.match_scroll = self.match_scroll.saturating_add(1);
196 }
197
198 pub fn scroll_explain_up(&mut self) {
199 self.explain_scroll = self.explain_scroll.saturating_sub(1);
200 }
201
202 pub fn scroll_explain_down(&mut self) {
203 self.explain_scroll = self.explain_scroll.saturating_add(1);
204 }
205
206 pub fn recompute(&mut self) {
207 let pattern = self.regex_editor.content().to_string();
208 self.match_scroll = 0;
209 self.explain_scroll = 0;
210 self.error_offset = None;
211
212 if pattern.is_empty() {
213 self.compiled = None;
214 self.matches.clear();
215 self.explanation.clear();
216 self.error = None;
217 self.compile_time = None;
218 self.match_time = None;
219 return;
220 }
221
222 let suggested = engine::detect_minimum_engine(&pattern);
225 if engine::is_engine_upgrade(self.engine_kind, suggested) {
226 let prev = self.engine_kind;
227 self.engine_kind = suggested;
228 self.engine = engine::create_engine(suggested);
229 self.set_status_message(format!(
230 "Auto-switched {} \u{2192} {} for this pattern",
231 prev, suggested,
232 ));
233 }
234
235 let compile_start = Instant::now();
237 match self.engine.compile(&pattern, &self.flags) {
238 Ok(compiled) => {
239 self.compile_time = Some(compile_start.elapsed());
240 self.compiled = Some(compiled);
241 self.error = None;
242 }
243 Err(e) => {
244 self.compile_time = Some(compile_start.elapsed());
245 self.compiled = None;
246 self.matches.clear();
247 self.error = Some(e.to_string());
248 }
249 }
250
251 match explain::explain(&pattern) {
253 Ok(nodes) => self.explanation = nodes,
254 Err((msg, offset)) => {
255 self.explanation.clear();
256 if self.error_offset.is_none() {
257 self.error_offset = offset;
258 }
259 if self.error.is_none() {
260 self.error = Some(msg);
261 }
262 }
263 }
264
265 self.rematch();
267 }
268
269 pub fn rematch(&mut self) {
270 self.match_scroll = 0;
271 self.selected_match = 0;
272 self.selected_capture = None;
273 if let Some(compiled) = &self.compiled {
274 let text = self.test_editor.content().to_string();
275 if text.is_empty() {
276 self.matches.clear();
277 self.replace_result = None;
278 self.match_time = None;
279 return;
280 }
281 let match_start = Instant::now();
282 match compiled.find_matches(&text) {
283 Ok(m) => {
284 self.match_time = Some(match_start.elapsed());
285 self.matches = m;
286 }
287 Err(e) => {
288 self.match_time = Some(match_start.elapsed());
289 self.matches.clear();
290 self.error = Some(e.to_string());
291 }
292 }
293 } else {
294 self.matches.clear();
295 self.match_time = None;
296 }
297 self.rereplace();
298 }
299
300 pub fn commit_pattern_to_history(&mut self) {
303 let pattern = self.regex_editor.content().to_string();
304 if pattern.is_empty() {
305 return;
306 }
307 if self.pattern_history.back().map(|s| s.as_str()) == Some(&pattern) {
308 return;
309 }
310 self.pattern_history.push_back(pattern);
311 if self.pattern_history.len() > MAX_PATTERN_HISTORY {
312 self.pattern_history.pop_front();
313 }
314 self.history_index = None;
315 self.history_temp = None;
316 }
317
318 pub fn history_prev(&mut self) {
319 if self.pattern_history.is_empty() {
320 return;
321 }
322 let new_index = match self.history_index {
323 Some(0) => return,
324 Some(idx) => idx - 1,
325 None => {
326 self.history_temp = Some(self.regex_editor.content().to_string());
327 self.pattern_history.len() - 1
328 }
329 };
330 self.history_index = Some(new_index);
331 let pattern = self.pattern_history[new_index].clone();
332 self.regex_editor = Editor::with_content(pattern);
333 self.recompute();
334 }
335
336 pub fn history_next(&mut self) {
337 let idx = match self.history_index {
338 Some(idx) => idx,
339 None => return,
340 };
341 if idx + 1 < self.pattern_history.len() {
342 let new_index = idx + 1;
343 self.history_index = Some(new_index);
344 let pattern = self.pattern_history[new_index].clone();
345 self.regex_editor = Editor::with_content(pattern);
346 self.recompute();
347 } else {
348 self.history_index = None;
350 let content = self.history_temp.take().unwrap_or_default();
351 self.regex_editor = Editor::with_content(content);
352 self.recompute();
353 }
354 }
355
356 pub fn select_match_next(&mut self) {
359 if self.matches.is_empty() {
360 return;
361 }
362 match self.selected_capture {
363 None => {
364 let m = &self.matches[self.selected_match];
365 if !m.captures.is_empty() {
366 self.selected_capture = Some(0);
367 } else if self.selected_match + 1 < self.matches.len() {
368 self.selected_match += 1;
369 }
370 }
371 Some(ci) => {
372 let m = &self.matches[self.selected_match];
373 if ci + 1 < m.captures.len() {
374 self.selected_capture = Some(ci + 1);
375 } else if self.selected_match + 1 < self.matches.len() {
376 self.selected_match += 1;
377 self.selected_capture = None;
378 }
379 }
380 }
381 self.scroll_to_selected();
382 }
383
384 pub fn select_match_prev(&mut self) {
385 if self.matches.is_empty() {
386 return;
387 }
388 match self.selected_capture {
389 Some(0) => {
390 self.selected_capture = None;
391 }
392 Some(ci) => {
393 self.selected_capture = Some(ci - 1);
394 }
395 None => {
396 if self.selected_match > 0 {
397 self.selected_match -= 1;
398 let m = &self.matches[self.selected_match];
399 if !m.captures.is_empty() {
400 self.selected_capture = Some(m.captures.len() - 1);
401 }
402 }
403 }
404 }
405 self.scroll_to_selected();
406 }
407
408 fn scroll_to_selected(&mut self) {
409 if self.matches.is_empty() || self.selected_match >= self.matches.len() {
410 return;
411 }
412 let mut line = 0usize;
413 for i in 0..self.selected_match {
414 line += 1 + self.matches[i].captures.len();
415 }
416 if let Some(ci) = self.selected_capture {
417 line += 1 + ci;
418 }
419 self.match_scroll = u16::try_from(line).unwrap_or(u16::MAX);
420 }
421
422 pub fn copy_selected_match(&mut self) {
423 let text = self.selected_text();
424 let Some(text) = text else { return };
425 let msg = format!("Copied: \"{}\"", truncate(&text, 40));
426 self.copy_to_clipboard(&text, &msg);
427 }
428
429 fn copy_to_clipboard(&mut self, text: &str, success_msg: &str) {
430 match arboard::Clipboard::new() {
431 Ok(mut cb) => match cb.set_text(text) {
432 Ok(()) => self.set_status_message(success_msg.to_string()),
433 Err(e) => self.set_status_message(format!("Clipboard error: {e}")),
434 },
435 Err(e) => self.set_status_message(format!("Clipboard error: {e}")),
436 }
437 }
438
439 pub fn set_status_message(&mut self, message: String) {
440 self.clipboard_status = Some(message);
441 self.clipboard_status_ticks = STATUS_DISPLAY_TICKS;
442 }
443
444 pub fn tick_clipboard_status(&mut self) -> bool {
446 if self.clipboard_status.is_some() {
447 if self.clipboard_status_ticks > 0 {
448 self.clipboard_status_ticks -= 1;
449 } else {
450 self.clipboard_status = None;
451 return true;
452 }
453 }
454 false
455 }
456
457 pub fn print_output(&self, group: Option<&str>, count: bool, color: bool) {
459 if count {
460 println!("{}", self.matches.len());
461 return;
462 }
463 if let Some(ref result) = self.replace_result {
464 if color {
465 print_colored_replace(&result.output, &result.segments);
466 } else {
467 print!("{}", result.output);
468 }
469 } else if let Some(group_spec) = group {
470 for m in &self.matches {
471 if let Some(text) = engine::lookup_capture(m, group_spec) {
472 if color {
473 println!("{RED_BOLD}{text}{RESET}");
474 } else {
475 println!("{text}");
476 }
477 } else {
478 eprintln!("rgx: group '{group_spec}' not found in match");
479 }
480 }
481 } else if color {
482 let text = self.test_editor.content();
483 print_colored_matches(text, &self.matches);
484 } else {
485 for m in &self.matches {
486 println!("{}", m.text);
487 }
488 }
489 }
490
491 pub fn print_json_output(&self) {
493 println!(
494 "{}",
495 serde_json::to_string_pretty(&self.matches).unwrap_or_else(|_| "[]".to_string())
496 );
497 }
498
499 fn selected_text(&self) -> Option<String> {
500 let m = self.matches.get(self.selected_match)?;
501 match self.selected_capture {
502 None => Some(m.text.clone()),
503 Some(ci) => m.captures.get(ci).map(|c| c.text.clone()),
504 }
505 }
506
507 pub fn edit_focused(&mut self, f: impl FnOnce(&mut Editor)) {
510 match self.focused_panel {
511 Self::PANEL_REGEX => {
512 f(&mut self.regex_editor);
513 self.recompute();
514 }
515 Self::PANEL_TEST => {
516 f(&mut self.test_editor);
517 self.rematch();
518 }
519 Self::PANEL_REPLACE => {
520 f(&mut self.replace_editor);
521 self.rereplace();
522 }
523 _ => {}
524 }
525 }
526
527 pub fn move_focused(&mut self, f: impl FnOnce(&mut Editor)) {
529 match self.focused_panel {
530 Self::PANEL_REGEX => f(&mut self.regex_editor),
531 Self::PANEL_TEST => f(&mut self.test_editor),
532 Self::PANEL_REPLACE => f(&mut self.replace_editor),
533 _ => {}
534 }
535 }
536
537 pub fn run_benchmark(&mut self) {
538 let pattern = self.regex_editor.content().to_string();
539 let text = self.test_editor.content().to_string();
540 if pattern.is_empty() || text.is_empty() {
541 return;
542 }
543
544 let mut results = Vec::new();
545 for kind in EngineKind::all() {
546 let eng = engine::create_engine(kind);
547 let compile_start = Instant::now();
548 let compiled = match eng.compile(&pattern, &self.flags) {
549 Ok(c) => c,
550 Err(e) => {
551 results.push(BenchmarkResult {
552 engine: kind,
553 compile_time: compile_start.elapsed(),
554 match_time: Duration::ZERO,
555 match_count: 0,
556 error: Some(e.to_string()),
557 });
558 continue;
559 }
560 };
561 let compile_time = compile_start.elapsed();
562 let match_start = Instant::now();
563 let (match_count, error) = match compiled.find_matches(&text) {
564 Ok(matches) => (matches.len(), None),
565 Err(e) => (0, Some(e.to_string())),
566 };
567 results.push(BenchmarkResult {
568 engine: kind,
569 compile_time,
570 match_time: match_start.elapsed(),
571 match_count,
572 error,
573 });
574 }
575 self.benchmark_results = results;
576 self.show_benchmark = true;
577 }
578
579 pub fn regex101_url(&self) -> String {
581 let pattern = self.regex_editor.content();
582 let test_string = self.test_editor.content();
583
584 let flavor = match self.engine_kind {
585 #[cfg(feature = "pcre2-engine")]
586 EngineKind::Pcre2 => "pcre2",
587 _ => "ecmascript",
588 };
589
590 let mut flags = String::from("g");
591 if self.flags.case_insensitive {
592 flags.push('i');
593 }
594 if self.flags.multi_line {
595 flags.push('m');
596 }
597 if self.flags.dot_matches_newline {
598 flags.push('s');
599 }
600 if self.flags.unicode {
601 flags.push('u');
602 }
603 if self.flags.extended {
604 flags.push('x');
605 }
606
607 format!(
608 "https://regex101.com/?regex={}&testString={}&flags={}&flavor={}",
609 url_encode(pattern),
610 url_encode(test_string),
611 url_encode(&flags),
612 flavor,
613 )
614 }
615
616 pub fn copy_regex101_url(&mut self) {
618 let url = self.regex101_url();
619 self.copy_to_clipboard(&url, "regex101 URL copied to clipboard");
620 }
621
622 pub fn generate_code(&mut self, lang: &crate::codegen::Language) {
624 let pattern = self.regex_editor.content().to_string();
625 if pattern.is_empty() {
626 self.set_status_message("No pattern to generate code for".to_string());
627 return;
628 }
629 let code = crate::codegen::generate_code(lang, &pattern, &self.flags);
630 self.copy_to_clipboard(&code, &format!("{} code copied to clipboard", lang));
631 self.show_codegen = false;
632 }
633
634 #[cfg(feature = "pcre2-engine")]
635 pub fn start_debug(&mut self, max_steps: usize) {
636 use crate::engine::pcre2_debug::{self, DebugSession};
637
638 let pattern = self.regex_editor.content().to_string();
639 let subject = self.test_editor.content().to_string();
640 if pattern.is_empty() || subject.is_empty() {
641 self.set_status_message("Debugger needs both a pattern and test string".to_string());
642 return;
643 }
644
645 if self.engine_kind != EngineKind::Pcre2 {
646 self.switch_engine_to(EngineKind::Pcre2);
647 self.recompute();
648 }
649
650 if let Some(ref cached) = self.debug_cache {
653 if cached.pattern == pattern && cached.subject == subject {
654 self.debug_session = self.debug_cache.take();
655 return;
656 }
657 }
658
659 let start_offset = self.selected_match_start();
660
661 match pcre2_debug::debug_match(&pattern, &subject, &self.flags, max_steps, start_offset) {
662 Ok(trace) => {
663 self.debug_session = Some(DebugSession {
664 trace,
665 step: 0,
666 show_heatmap: false,
667 pattern,
668 subject,
669 });
670 }
671 Err(e) => {
672 self.set_status_message(format!("Debugger error: {e}"));
673 }
674 }
675 }
676
677 #[cfg(not(feature = "pcre2-engine"))]
678 pub fn start_debug(&mut self, _max_steps: usize) {
679 self.set_status_message(
680 "Debugger requires PCRE2 (build with --features pcre2-engine)".to_string(),
681 );
682 }
683
684 #[cfg(feature = "pcre2-engine")]
685 fn selected_match_start(&self) -> usize {
686 if !self.matches.is_empty() && self.selected_match < self.matches.len() {
687 self.matches[self.selected_match].start
688 } else {
689 0
690 }
691 }
692
693 #[cfg(feature = "pcre2-engine")]
694 pub fn close_debug(&mut self) {
695 self.debug_cache = self.debug_session.take();
696 }
697
698 pub fn debug_step_forward(&mut self) {
699 #[cfg(feature = "pcre2-engine")]
700 if let Some(ref mut s) = self.debug_session {
701 if s.step + 1 < s.trace.steps.len() {
702 s.step += 1;
703 }
704 }
705 }
706
707 pub fn debug_step_back(&mut self) {
708 #[cfg(feature = "pcre2-engine")]
709 if let Some(ref mut s) = self.debug_session {
710 s.step = s.step.saturating_sub(1);
711 }
712 }
713
714 pub fn debug_jump_start(&mut self) {
715 #[cfg(feature = "pcre2-engine")]
716 if let Some(ref mut s) = self.debug_session {
717 s.step = 0;
718 }
719 }
720
721 pub fn debug_jump_end(&mut self) {
722 #[cfg(feature = "pcre2-engine")]
723 if let Some(ref mut s) = self.debug_session {
724 if !s.trace.steps.is_empty() {
725 s.step = s.trace.steps.len() - 1;
726 }
727 }
728 }
729
730 pub fn debug_next_match(&mut self) {
731 #[cfg(feature = "pcre2-engine")]
732 if let Some(ref mut s) = self.debug_session {
733 let current_attempt = s
734 .trace
735 .steps
736 .get(s.step)
737 .map(|st| st.match_attempt)
738 .unwrap_or(0);
739 for (i, step) in s.trace.steps.iter().enumerate().skip(s.step + 1) {
740 if step.match_attempt > current_attempt {
741 s.step = i;
742 return;
743 }
744 }
745 }
746 }
747
748 pub fn debug_next_backtrack(&mut self) {
749 #[cfg(feature = "pcre2-engine")]
750 if let Some(ref mut s) = self.debug_session {
751 for (i, step) in s.trace.steps.iter().enumerate().skip(s.step + 1) {
752 if step.is_backtrack {
753 s.step = i;
754 return;
755 }
756 }
757 }
758 }
759
760 pub fn debug_toggle_heatmap(&mut self) {
761 #[cfg(feature = "pcre2-engine")]
762 if let Some(ref mut s) = self.debug_session {
763 s.show_heatmap = !s.show_heatmap;
764 }
765 }
766}
767
768fn url_encode(s: &str) -> String {
769 let mut out = String::with_capacity(s.len() * 3);
770 for b in s.bytes() {
771 match b {
772 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
773 out.push(b as char);
774 }
775 _ => {
776 out.push_str(&format!("%{b:02X}"));
777 }
778 }
779 }
780 out
781}
782
783fn print_colored_matches(text: &str, matches: &[engine::Match]) {
784 let mut pos = 0;
785 for m in matches {
786 if m.start > pos {
787 print!("{}", &text[pos..m.start]);
788 }
789 print!("{RED_BOLD}{}{RESET}", &text[m.start..m.end]);
790 pos = m.end;
791 }
792 if pos < text.len() {
793 print!("{}", &text[pos..]);
794 }
795 if !text.ends_with('\n') {
796 println!();
797 }
798}
799
800fn print_colored_replace(output: &str, segments: &[engine::ReplaceSegment]) {
802 for seg in segments {
803 let chunk = &output[seg.start..seg.end];
804 if seg.is_replacement {
805 print!("{GREEN_BOLD}{chunk}{RESET}");
806 } else {
807 print!("{chunk}");
808 }
809 }
810 if !output.ends_with('\n') {
811 println!();
812 }
813}