Functional Search!

This commit is contained in:
Paul Julius Martinez 2021-11-13 18:30:02 -08:00
parent 5d10274949
commit b9d309a4b5
6 changed files with 376 additions and 1 deletions

116
SEARCH.md Normal file
View File

@ -0,0 +1,116 @@
# Search
## vim search
- Type /term, hit enter, jumps to it
- Bottom of screen says [M/N] indicating matches, then
specifies "W [1/N]" when wrapping
- With default settings:
- Cursor disappears when you hit '/'
- Matches get highlighted as you type
- Next match is white
- Other matches are yellow
- Matches stay highlighted
- There is a setting (`redrawtime`) that specifies max time
spent finding matches
- `hlsearch` indicates whether results are highlighted when done searching
- `incsearch` indicates whether results are highlighted while typing term
## Our initial implementation
- No highlighting as you type or after searching
- Need to store last search term
- Also computed regex
- Need to store list of matches
- We will find ALL matches at first
- Store them in a single vector?
- Do we need to store a index of our last jump anywhere?
- Multiple matches in a single line
- Store last match jumped to
## Desired behavior
- Incremental + highlight search until a non-search key is pressed
(either `n`/`N` or `*`/`#`)
- Need to store whether actively searching still
- Match is highlighted in string values
- How are multiline searches highlighted?
- How are keys highlighted?
- Need to handle true search vs. key search slightly differently
### Collapsed Containers
```
{
a: "apple",
b: [
"cherry",
"date",
],
c: "cherry",
}
{
a: "apple",
b: ["cherry", "date"],
c: "cherry",
}
```
When a match is found in a collapsed container (e.g., searching for
`cherry` above while `b` is highlighted), we will jump to that key/row,
and display a message "There are N matches inside this container", then
next search will continue after the collapsed container.
- Maybe there's a setting to automatically expand containers while
searching.
## Search State
```
struct SearchState {
mode: enum { Key, Free },
direction: enum { Down, Up },
search_term: String,
compiled_regex: Regex,
matches: Vec<Range<usize>>,
last_jump: usize,
actively_searching: bool,
}
```
## Search Inputs
`/` Start freeform search
`?` Start reverse freeform search
`*` Start object key search
`#` Start reverse object key search
`n` Go to next match
`N` Go to previous match
When starting search =>
- Create SearchState with actively searching `false`
- Need mode & direction naturally
- Need search term
- Need json text
- Then basically do a "go to next match"
## Messaging:
- Show search term in bottom while "actively searching"
- After each jump show `W? [M/N]`
- On collapsed containers display how many matches are inside
- "No matches found"
- Bad regex
## Tricky stuff
- Updating immediate search state appropriately

View File

@ -47,7 +47,7 @@ impl From<usize> for OptionIndex {
#[derive(Debug)]
pub struct FlatJson(
Vec<Row>,
pub Vec<Row>,
// Single-line pretty printed version of the JSON.
// Rows will contain references into this.
pub String,
@ -215,6 +215,13 @@ impl Row {
pub fn pair_index(&self) -> OptionIndex {
self.value.pair_index()
}
pub fn full_range(&self) -> Range<usize> {
match &self.key_range {
Some(key_range) => key_range.start..self.range.end,
None => self.range.clone(),
}
}
}
#[derive(Copy, Clone, Debug)]

View File

@ -10,6 +10,7 @@ use crate::flatjson;
use crate::input::TuiEvent;
use crate::input::TuiEvent::{KeyEvent, MouseEvent, WinChEvent};
use crate::screenwriter::{AnsiTTYWriter, ScreenWriter};
use crate::search::{JumpDirection, SearchDirection, SearchMode, SearchState};
use crate::types::TTYDimensions;
use crate::viewer::{Action, JsonViewer};
use crate::Opt;
@ -19,6 +20,7 @@ pub struct JLess {
screen_writer: ScreenWriter,
input_buffer: Vec<u8>,
input_filename: String,
search_state: SearchState,
}
pub const MAX_BUFFER_SIZE: usize = 9;
@ -54,6 +56,7 @@ pub fn new(
screen_writer,
input_buffer: vec![],
input_filename,
search_state: SearchState::empty(),
})
}
@ -120,6 +123,14 @@ impl JLess {
let lines = self.parse_input_buffer_as_number();
Some(Action::FocusNextSibling(lines))
}
Key::Char('n') => {
let count = self.parse_input_buffer_as_number();
Some(self.jump_to_next_search_match(count))
}
Key::Char('N') => {
let count = self.parse_input_buffer_as_number();
Some(self.jump_to_prev_search_match(count))
}
// These may interpret the input buffer some other way
Key::Char('t') => {
if self.input_buffer == "z".as_bytes() {
@ -163,6 +174,24 @@ impl JLess {
// Some(Action::Command(parse_command(_readline))
None
}
Key::Char('/') => {
let search_term = self.screen_writer.get_command().unwrap();
self.initialize_freeform_search(SearchDirection::Forward, search_term);
Some(self.jump_to_next_search_match(1))
}
Key::Char('?') => {
let search_term = self.screen_writer.get_command().unwrap();
self.initialize_freeform_search(SearchDirection::Reverse, search_term);
Some(self.jump_to_next_search_match(1))
}
Key::Char('*') => {
self.initialize_object_key_search(SearchDirection::Forward);
Some(self.jump_to_next_search_match(1))
}
Key::Char('#') => {
self.initialize_object_key_search(SearchDirection::Reverse);
Some(self.jump_to_next_search_match(1))
}
_ => {
print!("{}Got: {:?}\r", BELL, event);
None
@ -238,4 +267,33 @@ impl JLess {
self.input_buffer.clear();
n.unwrap_or(1)
}
fn initialize_freeform_search(&mut self, direction: SearchDirection, search_term: String) {
self.search_state = SearchState::initialize_search(
&search_term,
&self.viewer.flatjson.1,
SearchMode::Freeform,
direction,
);
}
fn initialize_object_key_search(&mut self, direction: SearchDirection) {}
fn jump_to_next_search_match(&mut self, jumps: usize) -> Action {
let destination = self.search_state.jump_to_match(
self.viewer.focused_row,
&self.viewer.flatjson,
JumpDirection::Next,
);
Action::MoveTo(destination)
}
fn jump_to_prev_search_match(&mut self, jumps: usize) -> Action {
let destination = self.search_state.jump_to_match(
self.viewer.focused_row,
&self.viewer.flatjson,
JumpDirection::Prev,
);
Action::MoveTo(destination)
}
}

View File

@ -18,6 +18,7 @@ mod jsonparser;
mod jsontokenizer;
mod lineprinter;
mod screenwriter;
mod search;
mod truncate;
mod tuicontrol;
mod types;

184
src/search.rs Normal file
View File

@ -0,0 +1,184 @@
use regex::Regex;
use std::ops::Range;
use crate::flatjson::{FlatJson, Index};
use crate::viewer::Action;
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub enum SearchMode {
// Searching for an Object key, initiated by '*' or '#'.
ObjectKey,
// Searching for freeform text, initiated by / or ?
Freeform,
}
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub enum SearchDirection {
Forward,
Reverse,
}
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub enum JumpDirection {
Next,
Prev,
}
pub struct SearchState {
mode: SearchMode,
direction: SearchDirection,
search_term: String,
compiled_regex: Regex,
matches: Vec<Range<usize>>,
immediate_state: ImmediateSearchState,
}
pub enum ImmediateSearchState {
NotSearching,
ActivelySearching {
last_match_jumped_to: usize,
last_search_into_collapsed_container: bool,
},
}
impl SearchState {
pub fn empty() -> SearchState {
SearchState {
mode: SearchMode::Freeform,
direction: SearchDirection::Forward,
search_term: "".to_owned(),
compiled_regex: Regex::new("").unwrap(),
matches: vec![],
immediate_state: ImmediateSearchState::NotSearching,
}
}
pub fn initialize_search(
needle: &str,
haystack: &str,
mode: SearchMode,
direction: SearchDirection,
) -> SearchState {
let regex = Regex::new(needle).unwrap();
let matches: Vec<Range<usize>> = regex.find_iter(haystack).map(|m| m.range()).collect();
SearchState {
mode,
direction,
search_term: needle.to_owned(),
compiled_regex: regex,
matches,
immediate_state: ImmediateSearchState::NotSearching,
}
}
pub fn jump_to_match(
&mut self,
focused_row: Index,
flatjson: &FlatJson,
jump_direction: JumpDirection,
) -> usize {
if self.matches.is_empty() {
eprintln!("NEED TO HANDLE NO MATCHES");
return 0;
}
let true_direction = self.true_direction(jump_direction);
let next_match_index = self.get_next_match(focused_row, flatjson, true_direction);
let destination_row = self.compute_destination_row(flatjson, next_match_index);
// TODO: Need to make sure that destination_row is not in a collapsed container.
destination_row
}
fn true_direction(&self, jump_direction: JumpDirection) -> SearchDirection {
match (self.direction, jump_direction) {
(SearchDirection::Forward, JumpDirection::Next) => SearchDirection::Forward,
(SearchDirection::Forward, JumpDirection::Prev) => SearchDirection::Reverse,
(SearchDirection::Reverse, JumpDirection::Next) => SearchDirection::Reverse,
(SearchDirection::Reverse, JumpDirection::Prev) => SearchDirection::Forward,
}
}
fn get_next_match(
&mut self,
focused_row: Index,
flatjson: &FlatJson,
true_direction: SearchDirection,
) -> usize {
match self.immediate_state {
ImmediateSearchState::NotSearching => {
let focused_row_range = flatjson[focused_row].full_range();
match true_direction {
SearchDirection::Forward => {
// When searching forwards, we want the first match that
// starts _after_ (or equal) the end of focused row.
let next_match = self.matches.partition_point(|match_range| {
match_range.start <= focused_row_range.end
});
// If NONE of the matches start after the end of the focused row,
// parition_point returns the length of the array, but then we
// want to jump back to the start in that case.
if next_match == self.matches.len() {
0
} else {
next_match
}
}
SearchDirection::Reverse => {
// When searching backwards, we want the last match that
// ends before the start of focused row.
let next_match = self.matches.partition_point(|match_range| {
match_range.end < focused_row_range.start
});
// If the very first match ends the start of the focused row,
// then partition_point will return 0, and we need to wrap
// around to the end of the file.
//
// But otherwise, partition_point will return the first match
// that didn't end before the start of the focused row, so we
// need to subtract 1.
if next_match == 0 {
self.matches.len() - 1
} else {
next_match - 1
}
}
}
}
ImmediateSearchState::ActivelySearching {
last_match_jumped_to,
last_search_into_collapsed_container,
} => {
let delta: isize = match true_direction {
SearchDirection::Forward => 1,
SearchDirection::Reverse => -1,
};
let next_match = ((last_match_jumped_to + self.matches.len()) as isize + delta)
as usize
% self.matches.len();
next_match
}
}
}
fn compute_destination_row(&self, flatjson: &FlatJson, match_index: usize) -> Index {
let match_range = &self.matches[match_index]; // [a, b)
// We want to jump to the last row that starts before (or at) the start of the match.
flatjson
.0
.partition_point(|row| row.full_range().start <= match_range.start)
- 1
}
}

View File

@ -54,10 +54,14 @@ impl JsonViewer {
#[derive(Debug, Copy, Clone)]
pub enum Action {
// Does nothing, for debugging, shouldn't modify any state.
NoOp,
MoveUp(usize),
MoveDown(usize),
MoveLeft,
MoveRight,
MoveTo(Index),
FocusParent,
@ -100,10 +104,12 @@ impl JsonViewer {
let reset_desired_depth = JsonViewer::should_reset_desired_depth(&action);
match action {
Action::NoOp => {}
Action::MoveUp(n) => self.move_up(n),
Action::MoveDown(n) => self.move_down(n),
Action::MoveLeft => self.move_left(),
Action::MoveRight => self.move_right(),
Action::MoveTo(index) => self.focused_row = index,
Action::FocusParent => self.focus_parent(),
Action::FocusPrevSibling(n) => self.focus_prev_sibling(n),
Action::FocusNextSibling(n) => self.focus_next_sibling(n),
@ -141,10 +147,12 @@ impl JsonViewer {
fn should_refocus_window(action: &Action) -> bool {
match action {
Action::NoOp => false,
Action::MoveUp(_) => true,
Action::MoveDown(_) => true,
Action::MoveLeft => true,
Action::MoveRight => true,
Action::MoveTo(_) => true,
Action::FocusParent => true,
Action::FocusPrevSibling(_) => true,
Action::FocusNextSibling(_) => true,
@ -171,6 +179,7 @@ impl JsonViewer {
fn should_reset_desired_depth(action: &Action) -> bool {
match action {
Action::NoOp => false,
Action::FocusPrevSibling(_) => false,
Action::FocusNextSibling(_) => false,
Action::ScrollUp(_) => false,