tracexec_tui/
query.rs

1use std::error::Error;
2
3use crossterm::event::{
4  KeyCode,
5  KeyEvent,
6  KeyModifiers,
7};
8use itertools::Itertools;
9use ratatui::{
10  style::Styled,
11  text::{
12    Line,
13    Span,
14  },
15  widgets::{
16    StatefulWidget,
17    Widget,
18  },
19};
20use tracexec_core::{
21  event::EventId,
22  primitives::regex::{
23    IntoCursor,
24    engines::pikevm,
25    regex_automata::util::syntax,
26  },
27};
28use tui_prompts::{
29  State,
30  TextPrompt,
31  TextState,
32};
33
34use super::{
35  event_line::EventLine,
36  help::help_item,
37  theme::THEME,
38};
39use crate::action::Action;
40
41#[derive(Debug, Clone)]
42pub struct Query {
43  pub kind: QueryKind,
44  pub value: QueryValue,
45  pub case_sensitive: bool,
46}
47
48#[derive(Debug, Clone)]
49pub enum QueryValue {
50  Regex(pikevm::PikeVM),
51  Text(String),
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum QueryKind {
56  Search,
57  Filter,
58}
59
60#[derive(Debug, Clone)]
61pub struct QueryResult {
62  /// The indices of matching events and the start of the match, use IndexMap to keep the order
63  pub indices: indexset::BTreeSet<EventId>,
64  /// The maximum of searched id
65  pub searched_id: EventId,
66  /// The currently focused item in query result, an index of `indices`
67  pub selection: Option<EventId>,
68}
69
70impl Query {
71  pub fn new(kind: QueryKind, value: QueryValue, case_sensitive: bool) -> Self {
72    Self {
73      kind,
74      value,
75      case_sensitive,
76    }
77  }
78
79  pub fn matches(&self, text: &EventLine) -> bool {
80    let result = match &self.value {
81      QueryValue::Regex(re) => pikevm::is_match(
82        re,
83        &mut pikevm::Cache::new(re),
84        &mut tracexec_core::primitives::regex::Input::new(text.into_cursor()),
85      ),
86      QueryValue::Text(query) => {
87        // FIXME: Use cursor.
88        if self.case_sensitive {
89          text.to_string().contains(query)
90        } else {
91          text
92            .to_string()
93            .to_lowercase()
94            .contains(&query.to_lowercase())
95        }
96      }
97    };
98    if result {
99      tracing::trace!("{text:?} matches: {self:?}");
100    }
101    result
102  }
103}
104
105impl QueryResult {
106  pub fn next_result(&mut self) {
107    if let Some(selection) = self.selection {
108      self.selection = match self.indices.range((selection + 1)..).next() {
109        Some(id) => Some(*id),
110        None => self.indices.first().copied(),
111      }
112    } else if !self.indices.is_empty() {
113      self.selection = self.indices.first().copied();
114    }
115  }
116
117  pub fn prev_result(&mut self) {
118    if let Some(selection) = self.selection {
119      self.selection = match self.indices.range(..selection).next_back() {
120        Some(id) => Some(*id),
121        None => self.indices.last().copied(),
122      };
123    } else if !self.indices.is_empty() {
124      self.selection = self.indices.last().copied();
125    }
126  }
127
128  /// Return the id of the currently selected event
129  pub fn selection(&self) -> Option<EventId> {
130    self.selection
131  }
132
133  pub fn statistics(&self) -> Line<'_> {
134    if self.indices.is_empty() {
135      "No match".set_style(THEME.query_no_match).into()
136    } else {
137      let total = self
138        .indices
139        .len()
140        .to_string()
141        .set_style(THEME.query_match_total_cnt);
142      let selected = self
143        .selection
144        .map(|index| self.indices.rank(&index) + 1)
145        .unwrap_or(0)
146        .to_string()
147        .set_style(THEME.query_match_current_no);
148      Line::default().spans(vec![selected, "/".into(), total])
149    }
150  }
151}
152
153pub struct QueryBuilder {
154  kind: QueryKind,
155  case_sensitive: bool,
156  is_regex: bool,
157  state: TextState<'static>,
158  editing: bool,
159}
160
161impl QueryBuilder {
162  pub fn new(kind: QueryKind) -> Self {
163    Self {
164      kind,
165      case_sensitive: false,
166      state: TextState::new(),
167      editing: true,
168      is_regex: false,
169    }
170  }
171
172  pub fn editing(&self) -> bool {
173    self.editing
174  }
175
176  pub fn edit(&mut self) {
177    self.editing = true;
178    self.state.focus();
179  }
180
181  /// Get the current cursor position,
182  /// this should be called after render is called
183  pub fn cursor(&self) -> (u16, u16) {
184    self.state.cursor()
185  }
186
187  pub fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>, Vec<Line<'static>>> {
188    match (key.code, key.modifiers) {
189      (KeyCode::Enter, _) => {
190        let text = self.state.value();
191        if text.is_empty() {
192          return Ok(Some(Action::EndSearch));
193        }
194        let query = Query::new(
195          self.kind,
196          if self.is_regex {
197            QueryValue::Regex(
198              pikevm::Builder::new()
199                .syntax(syntax::Config::new().case_insensitive(!self.case_sensitive))
200                .build(text)
201                .map_err(|e| {
202                  e.source()
203                    .unwrap() // We are directly building it from pattern text, the source syntax error is present
204                    .to_string()
205                    .lines()
206                    .map(|line| Line::raw(line.to_owned()))
207                    .collect_vec()
208                })?,
209            )
210          } else {
211            QueryValue::Text(text.to_owned())
212          },
213          self.case_sensitive,
214        );
215        self.editing = false;
216        return Ok(Some(Action::ExecuteSearch(query)));
217      }
218      (KeyCode::Esc, KeyModifiers::NONE) => {
219        return Ok(Some(Action::EndSearch));
220      }
221      (KeyCode::Char('i'), KeyModifiers::ALT) => {
222        self.case_sensitive = !self.case_sensitive;
223      }
224      (KeyCode::Char('r'), KeyModifiers::ALT) => {
225        self.is_regex = !self.is_regex;
226      }
227      _ => {
228        self.state.handle_key_event(key);
229      }
230    }
231    Ok(None)
232  }
233}
234
235impl QueryBuilder {
236  pub fn help(&self) -> Vec<Span<'_>> {
237    if self.editing {
238      [
239        help_item!("Esc", "Cancel\u{00a0}Search"),
240        help_item!("Enter", "Execute\u{00a0}Search"),
241        help_item!(
242          "Alt+I",
243          if self.case_sensitive {
244            "Case\u{00a0}Sensitive"
245          } else {
246            "Case\u{00a0}Insensitive"
247          }
248        ),
249        help_item!(
250          "Alt+R",
251          if self.is_regex {
252            "Regex\u{00a0}Mode"
253          } else {
254            "Text\u{00a0}Mode"
255          }
256        ),
257        help_item!("Ctrl+U", "Clear"),
258      ]
259      .into_iter()
260      .flatten()
261      .collect()
262    } else {
263      [
264        help_item!("N", "Next\u{00a0}Match"),
265        help_item!("P", "Previous\u{00a0}Match"),
266      ]
267      .into_iter()
268      .flatten()
269      .collect()
270    }
271  }
272}
273
274impl Widget for &mut QueryBuilder {
275  fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
276  where
277    Self: Sized,
278  {
279    TextPrompt::new(
280      match self.kind {
281        QueryKind::Search => "🔍",
282        QueryKind::Filter => "☔",
283      }
284      .into(),
285    )
286    .render(area, buf, &mut self.state);
287  }
288}
289
290#[cfg(test)]
291mod tests {
292  use crossterm::event::{
293    KeyCode,
294    KeyEvent,
295    KeyModifiers,
296  };
297  use ratatui::text::Line;
298  use tracexec_core::event::EventId;
299
300  use super::*;
301
302  fn make_event_line(text: &str) -> EventLine {
303    EventLine {
304      line: Line::from(text.to_string()),
305      cwd_mask: None,
306      env_mask: None,
307    }
308  }
309
310  #[test]
311  fn test_query_matches_text_case() {
312    let line = make_event_line("Hello World");
313
314    let q = Query::new(
315      QueryKind::Search,
316      QueryValue::Text("hello".to_string()),
317      false,
318    );
319    assert!(q.matches(&line));
320
321    let q = Query::new(
322      QueryKind::Search,
323      QueryValue::Text("hello".to_string()),
324      true,
325    );
326    assert!(!q.matches(&line));
327  }
328
329  #[test]
330  fn test_query_matches_regex() {
331    let line = make_event_line("abc123");
332    let re = pikevm::Builder::new()
333      .syntax(syntax::Config::new().case_insensitive(false))
334      .build(r"\d+")
335      .unwrap();
336    let q = Query::new(QueryKind::Search, QueryValue::Regex(re), false);
337    assert!(q.matches(&line));
338
339    let re = pikevm::Builder::new()
340      .syntax(syntax::Config::new())
341      .build(r"xyz")
342      .unwrap();
343    let q = Query::new(QueryKind::Search, QueryValue::Regex(re), false);
344    assert!(!q.matches(&line));
345  }
346
347  #[test]
348  fn test_query_result_navigation() {
349    let mut qr = QueryResult {
350      indices: vec![1, 3, 5].into_iter().map(EventId::new).collect(),
351      searched_id: EventId::new(5),
352      selection: None,
353    };
354
355    assert_eq!(qr.selection(), None);
356    qr.next_result();
357    assert_eq!(qr.selection(), Some(EventId::new(1)));
358    qr.next_result();
359    assert_eq!(qr.selection(), Some(EventId::new(3)));
360    qr.next_result();
361    assert_eq!(qr.selection(), Some(EventId::new(5)));
362    qr.next_result(); // wrap around
363    assert_eq!(qr.selection(), Some(EventId::new(1)));
364    qr.next_result();
365    assert_eq!(qr.selection(), Some(EventId::new(3)));
366
367    qr.prev_result();
368    assert_eq!(qr.selection(), Some(EventId::new(1)));
369    qr.prev_result(); // don't wrap around at start
370    assert_eq!(qr.selection(), Some(EventId::new(1)));
371    qr.prev_result(); // don't wrap around at start
372    assert_eq!(qr.selection(), Some(EventId::new(1)));
373  }
374
375  #[test]
376  fn test_query_result_statistics() {
377    let qr = QueryResult {
378      indices: vec![10, 20, 30].into_iter().map(EventId::new).collect(),
379      searched_id: EventId::new(30),
380      selection: Some(EventId::new(20)),
381    };
382
383    let line = qr.statistics();
384    let s = line.to_string();
385    assert!(s.contains("2")); // selected index
386    assert!(s.contains("3")); // total matches
387  }
388
389  #[test]
390  fn test_query_builder_toggle_flags_and_enter() {
391    let mut qb = QueryBuilder::new(QueryKind::Search);
392    assert!(qb.editing());
393    assert!(!qb.case_sensitive);
394    assert!(!qb.is_regex);
395
396    // Toggle case sensitivity
397    qb.handle_key_events(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::ALT))
398      .unwrap();
399    assert!(qb.case_sensitive);
400
401    // Toggle regex
402    qb.handle_key_events(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::ALT))
403      .unwrap();
404    assert!(qb.is_regex);
405
406    // Enter with empty input returns EndSearch
407    let mut empty_qb = QueryBuilder::new(QueryKind::Search);
408    let action = empty_qb
409      .handle_key_events(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
410      .unwrap();
411    assert!(matches!(action, Some(Action::EndSearch)));
412  }
413
414  #[test]
415  fn test_query_builder_edit_and_cursor() {
416    let mut qb = QueryBuilder::new(QueryKind::Search);
417    qb.edit();
418    assert!(qb.editing());
419
420    let cursor = qb.cursor();
421    assert_eq!(cursor, (0, 0));
422  }
423
424  #[test]
425  fn test_query_builder_help() {
426    let qb = QueryBuilder::new(QueryKind::Search);
427    let help = qb.help();
428    assert!(help.iter().any(|span| span.content.contains("Esc")));
429    assert!(help.iter().any(|span| span.content.contains("Enter")));
430  }
431}