Skip to main content

rgx/
app.rs

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; // ~2 seconds at 50ms tick rate
11
12#[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    // Pattern history
53    pub pattern_history: VecDeque<String>,
54    pub history_index: Option<usize>,
55    history_temp: Option<String>,
56    // Match selection + clipboard
57    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    /// Low-level engine setter. Does NOT call `recompute()` — the caller
184    /// must trigger recompilation separately if needed.
185    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        // Auto-select engine: upgrade (never downgrade) if the pattern
223        // requires a more powerful engine than the currently active one.
224        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        // Compile
236        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        // Explain (uses regex-syntax, independent of engine)
252        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        // Match
266        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    // --- Pattern history ---
301
302    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            // Past end — restore temp
349            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    // --- Match selection + clipboard ---
357
358    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    /// Tick down the clipboard status timer. Returns true if status was cleared.
445    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    /// Print match results or replacement output to stdout.
458    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    /// Print matches as structured JSON.
492    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    /// Apply a mutating editor operation to the currently focused editor panel,
508    /// then trigger the appropriate recompute/rematch/rereplace.
509    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    /// Apply a non-mutating cursor movement to the currently focused editor panel.
528    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    /// Generate a regex101.com URL from the current state.
580    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    /// Copy regex101 URL to clipboard.
617    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    /// Generate code for the current pattern in the given language and copy to clipboard.
623    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        // Restore cached session if pattern and subject haven't changed,
651        // preserving the user's step position and heatmap toggle.
652        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
800/// Print replacement output with replaced segments highlighted.
801fn 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}