Skip to main content

rgx/filter/
app.rs

1//! TUI-mode state for `rgx filter`.
2
3use crate::engine::{self, CompiledRegex, EngineFlags, EngineKind, RegexEngine};
4use crate::filter::FilterOptions;
5use crate::input::editor::Editor;
6
7pub struct FilterApp {
8    pub pattern_editor: Editor,
9    pub options: FilterOptions,
10    pub lines: Vec<String>,
11    /// Optional per-line extracted strings when the user passed `--json`.
12    /// Same length as `lines`. `None` at index `i` means line `i` should be
13    /// skipped (JSON parse failure, missing path, or non-string value). When
14    /// `json_extracted` is `None`, matching runs against the raw lines.
15    pub json_extracted: Option<Vec<Option<String>>>,
16    /// Indices of `lines` that currently match the pattern.
17    pub matched: Vec<usize>,
18    /// Byte ranges within each matched *input* that the pattern matched.
19    /// In `--json` mode these are spans within the extracted string, not the
20    /// raw line. Length equals `matched.len()`; empty per-line in invert mode.
21    pub match_spans: Vec<Vec<std::ops::Range<usize>>>,
22    /// Selected index into `matched` for the cursor in the match list.
23    pub selected: usize,
24    /// Scroll offset (first visible index into `matched`).
25    pub scroll: usize,
26    /// Compilation error from the last `recompute`, if any.
27    pub error: Option<String>,
28    /// Whether to quit the event loop on next tick.
29    pub should_quit: bool,
30    /// Outcome decided by the user: emit the filtered output, or discard.
31    pub outcome: Outcome,
32    engine: Box<dyn RegexEngine>,
33    engine_flags: EngineFlags,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Outcome {
38    Pending,
39    Emit,
40    Discard,
41}
42
43impl FilterApp {
44    pub fn new(lines: Vec<String>, initial_pattern: &str, options: FilterOptions) -> Self {
45        Self::build(lines, None, initial_pattern, options)
46    }
47
48    /// Construct a filter app whose matching runs against pre-extracted JSON
49    /// field values (from the `--json` flag). `extracted[i]` is `Some(s)` when
50    /// line `i` parsed and yielded a string value; `None` otherwise.
51    pub fn with_json_extracted(
52        lines: Vec<String>,
53        extracted: Vec<Option<String>>,
54        initial_pattern: &str,
55        options: FilterOptions,
56    ) -> Self {
57        assert_eq!(
58            lines.len(),
59            extracted.len(),
60            "extracted length must match lines length"
61        );
62        Self::build(lines, Some(extracted), initial_pattern, options)
63    }
64
65    fn build(
66        lines: Vec<String>,
67        json_extracted: Option<Vec<Option<String>>>,
68        initial_pattern: &str,
69        options: FilterOptions,
70    ) -> Self {
71        let pattern_editor = Editor::with_content(initial_pattern.to_string());
72        let engine_flags = EngineFlags {
73            case_insensitive: options.case_insensitive,
74            ..EngineFlags::default()
75        };
76        let engine = engine::create_engine(EngineKind::RustRegex);
77        let mut app = Self {
78            pattern_editor,
79            options,
80            lines,
81            json_extracted,
82            matched: Vec::new(),
83            match_spans: Vec::new(),
84            selected: 0,
85            scroll: 0,
86            error: None,
87            should_quit: false,
88            outcome: Outcome::Pending,
89            engine,
90            engine_flags,
91        };
92        app.recompute();
93        app
94    }
95
96    pub fn pattern(&self) -> &str {
97        self.pattern_editor.content()
98    }
99
100    pub fn recompute(&mut self) {
101        self.error = None;
102        let pattern = self.pattern().to_string();
103        if pattern.is_empty() {
104            // Empty pattern: every line with a "present" input passes (iff not
105            // inverted). In --json mode, lines whose extracted value is None
106            // are excluded regardless of invert.
107            self.matched = if let Some(extracted) = self.json_extracted.as_ref() {
108                extracted
109                    .iter()
110                    .enumerate()
111                    .filter_map(|(idx, v)| {
112                        if v.is_some() && !self.options.invert {
113                            Some(idx)
114                        } else {
115                            None
116                        }
117                    })
118                    .collect()
119            } else if self.options.invert {
120                Vec::new()
121            } else {
122                (0..self.lines.len()).collect()
123            };
124            // Nothing to highlight with an empty pattern.
125            self.match_spans = vec![Vec::new(); self.matched.len()];
126            self.clamp_selection();
127            return;
128        }
129        match self.engine.compile(&pattern, &self.engine_flags) {
130            Ok(compiled) => {
131                let (indices, spans) = self.collect_matches(&*compiled);
132                self.matched = indices;
133                self.match_spans = spans;
134                self.clamp_selection();
135            }
136            Err(err) => {
137                self.error = Some(err.to_string());
138                self.matched.clear();
139                self.match_spans.clear();
140                self.selected = 0;
141                self.scroll = 0;
142            }
143        }
144    }
145
146    fn collect_matches(
147        &self,
148        compiled: &dyn CompiledRegex,
149    ) -> (Vec<usize>, Vec<Vec<std::ops::Range<usize>>>) {
150        let mut indices = Vec::with_capacity(self.lines.len());
151        let mut all_spans = Vec::with_capacity(self.lines.len());
152        for idx in 0..self.lines.len() {
153            // In --json mode we match against the extracted field, not the
154            // raw line. None extracted values never match (and are excluded
155            // from invert-mode output too).
156            let haystack: &str = if let Some(extracted) = self.json_extracted.as_ref() {
157                match &extracted[idx] {
158                    Some(s) => s.as_str(),
159                    None => continue,
160                }
161            } else {
162                &self.lines[idx]
163            };
164            let line_matches = compiled.find_matches(haystack).unwrap_or_default();
165            let hit = !line_matches.is_empty();
166            if hit != self.options.invert {
167                indices.push(idx);
168                // In invert mode we emit lines that did NOT match — no spans
169                // to highlight per the task spec.
170                if self.options.invert {
171                    all_spans.push(Vec::new());
172                } else {
173                    all_spans.push(line_matches.into_iter().map(|m| m.start..m.end).collect());
174                }
175            }
176        }
177        (indices, all_spans)
178    }
179
180    fn clamp_selection(&mut self) {
181        if self.matched.is_empty() {
182            self.selected = 0;
183            self.scroll = 0;
184        } else if self.selected >= self.matched.len() {
185            self.selected = self.matched.len() - 1;
186        }
187    }
188
189    pub fn select_next(&mut self) {
190        if self.selected + 1 < self.matched.len() {
191            self.selected += 1;
192        }
193    }
194
195    pub fn select_prev(&mut self) {
196        self.selected = self.selected.saturating_sub(1);
197    }
198
199    pub fn toggle_case_insensitive(&mut self) {
200        self.options.case_insensitive = !self.options.case_insensitive;
201        self.engine_flags.case_insensitive = self.options.case_insensitive;
202        self.recompute();
203    }
204
205    pub fn toggle_invert(&mut self) {
206        self.options.invert = !self.options.invert;
207        self.recompute();
208    }
209}