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::{match_haystack, 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    ///
52    /// Returns `Err` when `extracted.len() != lines.len()` — callers must build
53    /// the extracted vector from the same `lines` slice (see `extract_strings`).
54    pub fn with_json_extracted(
55        lines: Vec<String>,
56        extracted: Vec<Option<String>>,
57        initial_pattern: &str,
58        options: FilterOptions,
59    ) -> Result<Self, String> {
60        if lines.len() != extracted.len() {
61            return Err(format!(
62                "extracted length ({}) must match lines length ({})",
63                extracted.len(),
64                lines.len()
65            ));
66        }
67        Ok(Self::build(
68            lines,
69            Some(extracted),
70            initial_pattern,
71            options,
72        ))
73    }
74
75    fn build(
76        lines: Vec<String>,
77        json_extracted: Option<Vec<Option<String>>>,
78        initial_pattern: &str,
79        options: FilterOptions,
80    ) -> Self {
81        let pattern_editor = Editor::with_content(initial_pattern.to_string());
82        let engine_flags = EngineFlags {
83            case_insensitive: options.case_insensitive,
84            ..EngineFlags::default()
85        };
86        let engine = engine::create_engine(EngineKind::RustRegex);
87        let mut app = Self {
88            pattern_editor,
89            options,
90            lines,
91            json_extracted,
92            matched: Vec::new(),
93            match_spans: Vec::new(),
94            selected: 0,
95            scroll: 0,
96            error: None,
97            should_quit: false,
98            outcome: Outcome::Pending,
99            engine,
100            engine_flags,
101        };
102        app.recompute();
103        app
104    }
105
106    pub fn pattern(&self) -> &str {
107        self.pattern_editor.content()
108    }
109
110    pub fn recompute(&mut self) {
111        self.error = None;
112        let pattern = self.pattern().to_string();
113        if pattern.is_empty() {
114            // Empty pattern matches every input: in invert mode that set is
115            // always empty. Otherwise in --json mode only the lines whose
116            // extracted value is Some; in raw mode every line.
117            self.matched = if self.options.invert {
118                Vec::new()
119            } else if let Some(extracted) = self.json_extracted.as_ref() {
120                extracted
121                    .iter()
122                    .enumerate()
123                    .filter_map(|(idx, v)| v.as_ref().map(|_| idx))
124                    .collect()
125            } else {
126                (0..self.lines.len()).collect()
127            };
128            // Nothing to highlight with an empty pattern.
129            self.match_spans = vec![Vec::new(); self.matched.len()];
130            self.clamp_selection();
131            return;
132        }
133        match self.engine.compile(&pattern, &self.engine_flags) {
134            Ok(compiled) => {
135                let (indices, spans) = self.collect_matches(&*compiled);
136                self.matched = indices;
137                self.match_spans = spans;
138                self.clamp_selection();
139            }
140            Err(err) => {
141                self.error = Some(err.to_string());
142                self.matched.clear();
143                self.match_spans.clear();
144                self.selected = 0;
145                self.scroll = 0;
146            }
147        }
148    }
149
150    fn collect_matches(
151        &self,
152        compiled: &dyn CompiledRegex,
153    ) -> (Vec<usize>, Vec<Vec<std::ops::Range<usize>>>) {
154        let mut indices = Vec::with_capacity(self.lines.len());
155        let mut all_spans = Vec::with_capacity(self.lines.len());
156        for idx in 0..self.lines.len() {
157            // In --json mode we match against the extracted field, not the
158            // raw line. None extracted values never match (and are excluded
159            // from invert-mode output too).
160            let haystack: &str = if let Some(extracted) = self.json_extracted.as_ref() {
161                match &extracted[idx] {
162                    Some(s) => s.as_str(),
163                    None => continue,
164                }
165            } else {
166                &self.lines[idx]
167            };
168            if let Some(spans) = match_haystack(compiled, haystack, self.options.invert) {
169                indices.push(idx);
170                all_spans.push(spans);
171            }
172        }
173        (indices, all_spans)
174    }
175
176    fn clamp_selection(&mut self) {
177        if self.matched.is_empty() {
178            self.selected = 0;
179            self.scroll = 0;
180        } else if self.selected >= self.matched.len() {
181            self.selected = self.matched.len() - 1;
182        }
183    }
184
185    pub fn select_next(&mut self) {
186        if self.selected + 1 < self.matched.len() {
187            self.selected += 1;
188        }
189    }
190
191    pub fn select_prev(&mut self) {
192        self.selected = self.selected.saturating_sub(1);
193    }
194
195    pub fn toggle_case_insensitive(&mut self) {
196        self.options.case_insensitive = !self.options.case_insensitive;
197        self.engine_flags.case_insensitive = self.options.case_insensitive;
198        self.recompute();
199    }
200
201    pub fn toggle_invert(&mut self) {
202        self.options.invert = !self.options.invert;
203        self.recompute();
204    }
205}