1use std::collections::VecDeque;
2use std::time::{Duration, Instant};
3
4use crate::engine::{self, CompiledRegex, EngineFlags, EngineKind, RegexEngine};
5use crate::explain::{self, ExplainNode};
6use crate::input::editor::Editor;
7
8const ANSI_RED_BOLD: &str = "\x1b[1;31m";
10const ANSI_GREEN_BOLD: &str = "\x1b[1;32m";
11const ANSI_RESET: &str = "\x1b[0m";
12
13#[derive(Debug, Clone)]
14pub struct BenchmarkResult {
15 pub engine: EngineKind,
16 pub compile_time: Duration,
17 pub match_time: Duration,
18 pub match_count: usize,
19 pub error: Option<String>,
20}
21
22fn truncate(s: &str, max_chars: usize) -> String {
23 let char_count = s.chars().count();
24 if char_count <= max_chars {
25 s.to_string()
26 } else {
27 let end = s
28 .char_indices()
29 .nth(max_chars)
30 .map(|(i, _)| i)
31 .unwrap_or(s.len());
32 format!("{}...", &s[..end])
33 }
34}
35
36pub struct App {
37 pub regex_editor: Editor,
38 pub test_editor: Editor,
39 pub replace_editor: Editor,
40 pub focused_panel: u8,
41 pub engine_kind: EngineKind,
42 pub flags: EngineFlags,
43 pub matches: Vec<engine::Match>,
44 pub replace_result: Option<engine::ReplaceResult>,
45 pub explanation: Vec<ExplainNode>,
46 pub error: Option<String>,
47 pub show_help: bool,
48 pub help_page: usize,
49 pub should_quit: bool,
50 pub match_scroll: u16,
51 pub replace_scroll: u16,
52 pub explain_scroll: u16,
53 pub pattern_history: VecDeque<String>,
55 pub history_index: Option<usize>,
56 history_temp: Option<String>,
57 pub selected_match: usize,
59 pub selected_capture: Option<usize>,
60 pub clipboard_status: Option<String>,
61 clipboard_status_ticks: u32,
62 pub show_whitespace: bool,
63 pub rounded_borders: bool,
64 pub vim_mode: bool,
65 pub vim_state: crate::input::vim::VimState,
66 pub compile_time: Option<Duration>,
67 pub match_time: Option<Duration>,
68 pub error_offset: Option<usize>,
69 pub output_on_quit: bool,
70 pub workspace_path: Option<String>,
71 pub show_recipes: bool,
72 pub recipe_index: usize,
73 pub show_benchmark: bool,
74 pub benchmark_results: Vec<BenchmarkResult>,
75 engine: Box<dyn RegexEngine>,
76 compiled: Option<Box<dyn CompiledRegex>>,
77}
78
79impl App {
80 pub const PANEL_REGEX: u8 = 0;
81 pub const PANEL_TEST: u8 = 1;
82 pub const PANEL_REPLACE: u8 = 2;
83 pub const PANEL_MATCHES: u8 = 3;
84 pub const PANEL_EXPLAIN: u8 = 4;
85 pub const PANEL_COUNT: u8 = 5;
86}
87
88impl App {
89 pub fn new(engine_kind: EngineKind, flags: EngineFlags) -> Self {
90 let engine = engine::create_engine(engine_kind);
91 Self {
92 regex_editor: Editor::new(),
93 test_editor: Editor::new(),
94 replace_editor: Editor::new(),
95 focused_panel: 0,
96 engine_kind,
97 flags,
98 matches: Vec::new(),
99 replace_result: None,
100 explanation: Vec::new(),
101 error: None,
102 show_help: false,
103 help_page: 0,
104 should_quit: false,
105 match_scroll: 0,
106 replace_scroll: 0,
107 explain_scroll: 0,
108 pattern_history: VecDeque::new(),
109 history_index: None,
110 history_temp: None,
111 selected_match: 0,
112 selected_capture: None,
113 clipboard_status: None,
114 clipboard_status_ticks: 0,
115 show_whitespace: false,
116 rounded_borders: false,
117 vim_mode: false,
118 vim_state: crate::input::vim::VimState::new(),
119 compile_time: None,
120 match_time: None,
121 error_offset: None,
122 output_on_quit: false,
123 workspace_path: None,
124 show_recipes: false,
125 recipe_index: 0,
126 show_benchmark: false,
127 benchmark_results: Vec::new(),
128 engine,
129 compiled: None,
130 }
131 }
132
133 pub fn set_replacement(&mut self, text: &str) {
134 self.replace_editor = Editor::with_content(text.to_string());
135 self.rereplace();
136 }
137
138 pub fn scroll_replace_up(&mut self) {
139 self.replace_scroll = self.replace_scroll.saturating_sub(1);
140 }
141
142 pub fn scroll_replace_down(&mut self) {
143 self.replace_scroll = self.replace_scroll.saturating_add(1);
144 }
145
146 pub fn rereplace(&mut self) {
147 let template = self.replace_editor.content().to_string();
148 if template.is_empty() || self.matches.is_empty() {
149 self.replace_result = None;
150 return;
151 }
152 let text = self.test_editor.content().to_string();
153 self.replace_result = Some(engine::replace_all(&text, &self.matches, &template));
154 }
155
156 pub fn set_pattern(&mut self, pattern: &str) {
157 self.regex_editor = Editor::with_content(pattern.to_string());
158 self.recompute();
159 }
160
161 pub fn set_test_string(&mut self, text: &str) {
162 self.test_editor = Editor::with_content(text.to_string());
163 self.rematch();
164 }
165
166 pub fn switch_engine(&mut self) {
167 self.engine_kind = self.engine_kind.next();
168 self.engine = engine::create_engine(self.engine_kind);
169 self.recompute();
170 }
171
172 pub fn switch_engine_to(&mut self, kind: EngineKind) {
173 self.engine_kind = kind;
174 self.engine = engine::create_engine(kind);
175 }
176
177 pub fn scroll_match_up(&mut self) {
178 self.match_scroll = self.match_scroll.saturating_sub(1);
179 }
180
181 pub fn scroll_match_down(&mut self) {
182 self.match_scroll = self.match_scroll.saturating_add(1);
183 }
184
185 pub fn scroll_explain_up(&mut self) {
186 self.explain_scroll = self.explain_scroll.saturating_sub(1);
187 }
188
189 pub fn scroll_explain_down(&mut self) {
190 self.explain_scroll = self.explain_scroll.saturating_add(1);
191 }
192
193 pub fn recompute(&mut self) {
194 let pattern = self.regex_editor.content().to_string();
195 self.match_scroll = 0;
196 self.explain_scroll = 0;
197 self.error_offset = None;
198
199 if pattern.is_empty() {
200 self.compiled = None;
201 self.matches.clear();
202 self.explanation.clear();
203 self.error = None;
204 self.compile_time = None;
205 self.match_time = None;
206 return;
207 }
208
209 let compile_start = Instant::now();
211 match self.engine.compile(&pattern, &self.flags) {
212 Ok(compiled) => {
213 self.compile_time = Some(compile_start.elapsed());
214 self.compiled = Some(compiled);
215 self.error = None;
216 }
217 Err(e) => {
218 self.compile_time = Some(compile_start.elapsed());
219 self.compiled = None;
220 self.matches.clear();
221 self.error = Some(e.to_string());
222 }
223 }
224
225 match explain::explain(&pattern) {
227 Ok(nodes) => self.explanation = nodes,
228 Err((msg, offset)) => {
229 self.explanation.clear();
230 if self.error_offset.is_none() {
231 self.error_offset = offset;
232 }
233 if self.error.is_none() {
234 self.error = Some(msg);
235 }
236 }
237 }
238
239 self.rematch();
241 }
242
243 pub fn rematch(&mut self) {
244 self.match_scroll = 0;
245 self.selected_match = 0;
246 self.selected_capture = None;
247 if let Some(compiled) = &self.compiled {
248 let text = self.test_editor.content().to_string();
249 if text.is_empty() {
250 self.matches.clear();
251 self.replace_result = None;
252 self.match_time = None;
253 return;
254 }
255 let match_start = Instant::now();
256 match compiled.find_matches(&text) {
257 Ok(m) => {
258 self.match_time = Some(match_start.elapsed());
259 self.matches = m;
260 }
261 Err(e) => {
262 self.match_time = Some(match_start.elapsed());
263 self.matches.clear();
264 self.error = Some(e.to_string());
265 }
266 }
267 } else {
268 self.matches.clear();
269 self.match_time = None;
270 }
271 self.rereplace();
272 }
273
274 pub fn commit_pattern_to_history(&mut self) {
277 let pattern = self.regex_editor.content().to_string();
278 if pattern.is_empty() {
279 return;
280 }
281 if self.pattern_history.back().map(|s| s.as_str()) == Some(&pattern) {
282 return;
283 }
284 self.pattern_history.push_back(pattern);
285 if self.pattern_history.len() > 100 {
286 self.pattern_history.pop_front();
287 }
288 self.history_index = None;
289 self.history_temp = None;
290 }
291
292 pub fn history_prev(&mut self) {
293 if self.pattern_history.is_empty() {
294 return;
295 }
296 let new_index = match self.history_index {
297 Some(0) => return,
298 Some(idx) => idx - 1,
299 None => {
300 self.history_temp = Some(self.regex_editor.content().to_string());
301 self.pattern_history.len() - 1
302 }
303 };
304 self.history_index = Some(new_index);
305 let pattern = self.pattern_history[new_index].clone();
306 self.regex_editor = Editor::with_content(pattern);
307 self.recompute();
308 }
309
310 pub fn history_next(&mut self) {
311 let idx = match self.history_index {
312 Some(idx) => idx,
313 None => return,
314 };
315 if idx + 1 < self.pattern_history.len() {
316 let new_index = idx + 1;
317 self.history_index = Some(new_index);
318 let pattern = self.pattern_history[new_index].clone();
319 self.regex_editor = Editor::with_content(pattern);
320 self.recompute();
321 } else {
322 self.history_index = None;
324 let content = self.history_temp.take().unwrap_or_default();
325 self.regex_editor = Editor::with_content(content);
326 self.recompute();
327 }
328 }
329
330 pub fn select_match_next(&mut self) {
333 if self.matches.is_empty() {
334 return;
335 }
336 match self.selected_capture {
337 None => {
338 let m = &self.matches[self.selected_match];
339 if !m.captures.is_empty() {
340 self.selected_capture = Some(0);
341 } else if self.selected_match + 1 < self.matches.len() {
342 self.selected_match += 1;
343 }
344 }
345 Some(ci) => {
346 let m = &self.matches[self.selected_match];
347 if ci + 1 < m.captures.len() {
348 self.selected_capture = Some(ci + 1);
349 } else if self.selected_match + 1 < self.matches.len() {
350 self.selected_match += 1;
351 self.selected_capture = None;
352 }
353 }
354 }
355 self.scroll_to_selected();
356 }
357
358 pub fn select_match_prev(&mut self) {
359 if self.matches.is_empty() {
360 return;
361 }
362 match self.selected_capture {
363 Some(0) => {
364 self.selected_capture = None;
365 }
366 Some(ci) => {
367 self.selected_capture = Some(ci - 1);
368 }
369 None => {
370 if self.selected_match > 0 {
371 self.selected_match -= 1;
372 let m = &self.matches[self.selected_match];
373 if !m.captures.is_empty() {
374 self.selected_capture = Some(m.captures.len() - 1);
375 }
376 }
377 }
378 }
379 self.scroll_to_selected();
380 }
381
382 fn scroll_to_selected(&mut self) {
383 if self.matches.is_empty() || self.selected_match >= self.matches.len() {
384 return;
385 }
386 let mut line = 0usize;
387 for i in 0..self.selected_match {
388 line += 1 + self.matches[i].captures.len();
389 }
390 if let Some(ci) = self.selected_capture {
391 line += 1 + ci;
392 }
393 self.match_scroll = u16::try_from(line).unwrap_or(u16::MAX);
394 }
395
396 pub fn copy_selected_match(&mut self) {
397 let text = self.selected_text();
398 let Some(text) = text else { return };
399 let msg = format!("Copied: \"{}\"", truncate(&text, 40));
400 self.copy_to_clipboard(&text, &msg);
401 }
402
403 fn copy_to_clipboard(&mut self, text: &str, success_msg: &str) {
404 match arboard::Clipboard::new() {
405 Ok(mut cb) => match cb.set_text(text) {
406 Ok(()) => self.set_status_message(success_msg.to_string()),
407 Err(e) => self.set_status_message(format!("Clipboard error: {e}")),
408 },
409 Err(e) => self.set_status_message(format!("Clipboard error: {e}")),
410 }
411 }
412
413 pub fn set_status_message(&mut self, message: String) {
414 self.clipboard_status = Some(message);
415 self.clipboard_status_ticks = 40; }
417
418 pub fn tick_clipboard_status(&mut self) -> bool {
420 if self.clipboard_status.is_some() {
421 if self.clipboard_status_ticks > 0 {
422 self.clipboard_status_ticks -= 1;
423 } else {
424 self.clipboard_status = None;
425 return true;
426 }
427 }
428 false
429 }
430
431 pub fn print_output(&self, group: Option<&str>, count: bool, color: bool) {
433 if count {
434 println!("{}", self.matches.len());
435 return;
436 }
437 if let Some(ref result) = self.replace_result {
438 if color {
439 print_colored_replace(&result.output, &result.segments);
440 } else {
441 print!("{}", result.output);
442 }
443 } else if let Some(group_spec) = group {
444 for m in &self.matches {
445 if let Some(text) = engine::lookup_capture(m, group_spec) {
446 if color {
447 println!("{ANSI_RED_BOLD}{text}{ANSI_RESET}");
448 } else {
449 println!("{text}");
450 }
451 } else {
452 eprintln!("rgx: group '{group_spec}' not found in match");
453 }
454 }
455 } else if color {
456 let text = self.test_editor.content();
457 print_colored_matches(text, &self.matches);
458 } else {
459 for m in &self.matches {
460 println!("{}", m.text);
461 }
462 }
463 }
464
465 pub fn print_json_output(&self) {
467 println!(
468 "{}",
469 serde_json::to_string_pretty(&self.matches).unwrap_or_else(|_| "[]".to_string())
470 );
471 }
472
473 fn selected_text(&self) -> Option<String> {
474 let m = self.matches.get(self.selected_match)?;
475 match self.selected_capture {
476 None => Some(m.text.clone()),
477 Some(ci) => m.captures.get(ci).map(|c| c.text.clone()),
478 }
479 }
480
481 pub fn edit_focused(&mut self, f: impl FnOnce(&mut Editor)) {
484 match self.focused_panel {
485 Self::PANEL_REGEX => {
486 f(&mut self.regex_editor);
487 self.recompute();
488 }
489 Self::PANEL_TEST => {
490 f(&mut self.test_editor);
491 self.rematch();
492 }
493 Self::PANEL_REPLACE => {
494 f(&mut self.replace_editor);
495 self.rereplace();
496 }
497 _ => {}
498 }
499 }
500
501 pub fn move_focused(&mut self, f: impl FnOnce(&mut Editor)) {
503 match self.focused_panel {
504 Self::PANEL_REGEX => f(&mut self.regex_editor),
505 Self::PANEL_TEST => f(&mut self.test_editor),
506 Self::PANEL_REPLACE => f(&mut self.replace_editor),
507 _ => {}
508 }
509 }
510
511 pub fn run_benchmark(&mut self) {
512 let pattern = self.regex_editor.content().to_string();
513 let text = self.test_editor.content().to_string();
514 if pattern.is_empty() || text.is_empty() {
515 return;
516 }
517
518 let mut results = Vec::new();
519 for kind in EngineKind::all() {
520 let eng = engine::create_engine(kind);
521 let compile_start = Instant::now();
522 let compiled = match eng.compile(&pattern, &self.flags) {
523 Ok(c) => c,
524 Err(e) => {
525 results.push(BenchmarkResult {
526 engine: kind,
527 compile_time: compile_start.elapsed(),
528 match_time: Duration::ZERO,
529 match_count: 0,
530 error: Some(e.to_string()),
531 });
532 continue;
533 }
534 };
535 let compile_time = compile_start.elapsed();
536 let match_start = Instant::now();
537 let (match_count, error) = match compiled.find_matches(&text) {
538 Ok(matches) => (matches.len(), None),
539 Err(e) => (0, Some(e.to_string())),
540 };
541 results.push(BenchmarkResult {
542 engine: kind,
543 compile_time,
544 match_time: match_start.elapsed(),
545 match_count,
546 error,
547 });
548 }
549 self.benchmark_results = results;
550 self.show_benchmark = true;
551 }
552
553 pub fn regex101_url(&self) -> String {
555 let pattern = self.regex_editor.content();
556 let test_string = self.test_editor.content();
557
558 let flavor = match self.engine_kind {
559 #[cfg(feature = "pcre2-engine")]
560 EngineKind::Pcre2 => "pcre2",
561 _ => "ecmascript",
562 };
563
564 let mut flags = String::from("g");
565 if self.flags.case_insensitive {
566 flags.push('i');
567 }
568 if self.flags.multi_line {
569 flags.push('m');
570 }
571 if self.flags.dot_matches_newline {
572 flags.push('s');
573 }
574 if self.flags.unicode {
575 flags.push('u');
576 }
577 if self.flags.extended {
578 flags.push('x');
579 }
580
581 fn url_encode(s: &str) -> String {
582 let mut out = String::with_capacity(s.len() * 3);
583 for b in s.bytes() {
584 match b {
585 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
586 out.push(b as char);
587 }
588 _ => {
589 out.push_str(&format!("%{b:02X}"));
590 }
591 }
592 }
593 out
594 }
595
596 format!(
597 "https://regex101.com/?regex={}&testString={}&flags={}&flavor={}",
598 url_encode(pattern),
599 url_encode(test_string),
600 url_encode(&flags),
601 flavor,
602 )
603 }
604
605 pub fn copy_regex101_url(&mut self) {
607 let url = self.regex101_url();
608 self.copy_to_clipboard(&url, "regex101 URL copied to clipboard");
609 }
610}
611
612fn print_colored_matches(text: &str, matches: &[engine::Match]) {
614 let mut pos = 0;
615 for m in matches {
616 if m.start > pos {
617 print!("{}", &text[pos..m.start]);
618 }
619 print!("{ANSI_RED_BOLD}{}{ANSI_RESET}", &text[m.start..m.end]);
620 pos = m.end;
621 }
622 if pos < text.len() {
623 print!("{}", &text[pos..]);
624 }
625 if !text.ends_with('\n') {
626 println!();
627 }
628}
629
630fn print_colored_replace(output: &str, segments: &[engine::ReplaceSegment]) {
632 for seg in segments {
633 let chunk = &output[seg.start..seg.end];
634 if seg.is_replacement {
635 print!("{ANSI_GREEN_BOLD}{chunk}{ANSI_RESET}");
636 } else {
637 print!("{chunk}");
638 }
639 }
640 if !output.ends_with('\n') {
641 println!();
642 }
643}