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