use std::path::PathBuf;
use std::{error::Error, fs::File};
use std::{fmt, io::Stdout};
use std::{
fs::OpenOptions,
io::{prelude::*, BufReader},
};
use crossbeam::thread;
use std::io::Write;
use termion::cursor;
use termion::input::TermRead;
use termion::raw::IntoRawMode;
use termion::{event::Key, raw::RawTerminal};
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use itertools::Itertools;
#[derive(Debug, Clone)]
enum Errors {
ParseCommandError = 1,
}
impl fmt::Display for Errors {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let error_text = match self {
Errors::ParseCommandError => "fail to parse command",
};
write!(f, "{}", error_text)
}
}
impl Error for Errors {}
#[derive(Debug, Clone)]
pub struct Command {
id: u32,
command: String,
}
impl Command {
pub fn new(id: u32, command: String) -> Command {
Command { id, command }
}
pub fn from_string(s: &str) -> Result<Command, Box<dyn Error>> {
let str = String::from(s);
let id = str.get(2..12).ok_or(Errors::ParseCommandError)?;
let cmd = str.get(15..).ok_or(Errors::ParseCommandError)?;
Ok(Command::new(id.parse::<u32>()?, String::from(cmd.trim())))
}
pub fn get_match_score(&self, s: &String) -> i64 {
let matcher = SkimMatcherV2::default();
let score = matcher
.fuzzy_indices(&self.command, s)
.map(|(score, _)| score)
.unwrap_or(-1508);
return score;
}
pub fn truncate_command(&self, max_len: u16) -> String {
let cmd: String = self
.command
.chars()
.filter_map(|c| match c {
'\r' => None,
'\n' => Some(' '),
_ => Some(c),
})
.collect();
let size = std::cmp::min(cmd.len(), usize::from(max_len));
cmd.chars().take(size).collect()
}
}
#[derive(Debug)]
pub struct Finder {
commands: Vec<Command>,
query: String,
}
impl Finder {
pub fn new(commands: Vec<Command>, query: String) -> Finder {
Finder { commands, query }
}
pub fn new_without_query(commands: Vec<Command>) -> Finder {
Finder::new(commands, String::from(""))
}
pub fn new_with_bash_history() -> Result<Finder, Box<dyn Error>> {
let paths = Finder::get_history_file_path();
let mut all_commands: Vec<Command> = vec![];
for path in paths {
let f_res = File::open(&path);
if f_res.is_err() {
continue;
}
let f = f_res?;
let buf_reader = BufReader::new(f);
let lines: Vec<String> = buf_reader.lines().filter_map(|line| line.ok()).collect();
let mut commands_str: Vec<String> = vec![];
let mut cur_command = String::from("");
lines.iter().for_each(|line| {
let first_char = line.chars().nth(0).unwrap_or('?');
if first_char == ':' {
commands_str.push(cur_command.clone());
cur_command = String::from(line);
} else {
cur_command.push_str(&format!("{}\r\n", line));
};
});
if !cur_command.is_empty() {
commands_str.push(cur_command);
}
let mut commands: Vec<Command> = commands_str
.iter()
.filter_map(|cmd_str| match Command::from_string(cmd_str) {
Ok(cmd) => Some(cmd),
Err(_) => None,
})
.collect();
all_commands.append(&mut commands);
}
Ok(Finder::new_without_query(all_commands))
}
fn get_history_file_path() -> Vec<PathBuf> {
let res = if let Ok(hist_file) = std::env::var("HISTFILE") {
vec![PathBuf::from(hist_file)]
} else {
if let Ok(shell_path) = std::env::var("SHELL") {
if shell_path.contains("zsh") {
if let Ok(home_path) = std::env::var("HOME") {
vec![
PathBuf::from(format!("{}/.zhistory", home_path)),
PathBuf::from(format!("{}/.zsh_history", home_path)),
]
} else {
vec![]
}
} else {
vec![]
}
} else {
vec![]
}
};
res
}
pub fn update_query(&mut self, new_query: String) {
self.query = new_query
}
pub fn get_matched_commands<'a, 'b>(
commands: &'a Vec<Command>,
query: &'b String,
) -> Vec<&'a Command> {
fn get_score<'a>(commands: &'a [Command], query: &String) -> Vec<(&'a Command, i64)> {
let result: Vec<(&Command, i64)> = commands
.iter()
.map(|cmd| (cmd, cmd.get_match_score(&query)))
.collect();
result
}
if query.is_empty() {
let mut result: Vec<&Command> = commands.into_iter().collect();
result.reverse();
return result;
}
const NTHREAD: usize = 8;
let job_chunks = commands.chunks(commands.len() / NTHREAD);
let mut result = thread::scope(|s| {
let mut handles = vec![];
for chunk in job_chunks {
handles.push(s.spawn(move |_| get_score(chunk, query)));
}
let mut result = vec![];
for handle in handles {
let mut chunk_result = handle.join().unwrap();
result.append(&mut chunk_result);
}
result
})
.unwrap();
result.sort_by_key(|k| k.1);
result.reverse();
let ranked_result: Vec<&Command> = result
.into_iter()
.map(|k| k.0)
.unique_by(|cmd| &cmd.command)
.collect();
ranked_result
}
const NUM_SUGGESTIONS: usize = 15;
pub fn render(&mut self) -> Result<(), Box<dyn Error>> {
let mut stdout = std::io::stdout().into_raw_mode()?;
let blank_lines: String = (0..=Finder::NUM_SUGGESTIONS).map(|_| "\n").collect();
let move_cursor_up = format!("{}", cursor::Up((Finder::NUM_SUGGESTIONS + 1) as u16));
write!(stdout, "{}{}{}", blank_lines, move_cursor_up, cursor::Save)?;
stdout.flush()?;
let commands = self.commands.clone();
let mut selecting_cmd = 0usize;
let mut truncated_matches = Finder::get_truncated_matches(&commands, &self.query);
Finder::output_matched_commands(&truncated_matches, selecting_cmd, &mut stdout)?;
let mut stdin = termion::async_stdin().keys();
loop {
let key = stdin.next();
if let Some(Ok(c)) = key {
match c {
Key::Ctrl('p') | Key::Up => {
selecting_cmd = selecting_cmd.checked_sub(1).unwrap_or(0);
}
Key::Ctrl('n') | Key::Down => {
selecting_cmd =
std::cmp::min(selecting_cmd + 1, Finder::NUM_SUGGESTIONS - 1);
}
Key::Ctrl('c') => {
break;
}
Key::Ctrl('e') => {
Finder::output_command_to_file(&truncated_matches, selecting_cmd, true)?;
break;
}
Key::Char('\n') => {
Finder::output_command_to_file(&truncated_matches, selecting_cmd, false)?;
break;
}
Key::Char(ch) => {
let new_query = format!("{}{}", self.query, ch);
truncated_matches = Finder::get_truncated_matches(&commands, &new_query);
self.update_query(new_query)
}
Key::Backspace => {
let new_query = if self.query.len() > 0 {
self.query.chars().take(self.query.len() - 1).collect()
} else {
String::from("")
};
truncated_matches = Finder::get_truncated_matches(&commands, &new_query);
self.update_query(new_query)
}
_ => {}
}
write!(
stdout,
"{}{}{}",
cursor::Restore,
cursor::Save,
termion::clear::AfterCursor
)?;
write!(stdout, "{}\r\n", self.query)?;
Finder::output_matched_commands(&truncated_matches, selecting_cmd, &mut stdout)?;
}
}
Ok(())
}
fn get_truncated_matches<'a, 'b>(
commands: &'a Vec<Command>,
query: &'b String,
) -> Vec<&'a Command> {
let matches = Finder::get_matched_commands(commands, query);
if matches.len() > Finder::NUM_SUGGESTIONS {
let (left, _) = matches.split_at(Finder::NUM_SUGGESTIONS);
left.to_vec()
} else {
matches
}
}
fn get_selecting_command(commands: &Vec<&Command>, selecting_cmd: usize) -> String {
commands
.get(selecting_cmd)
.map(|cmd| cmd.command.clone())
.unwrap_or(String::from(""))
}
fn output_command_to_file(
commands: &Vec<&Command>,
selecting_cmd: usize,
execute: bool,
) -> Result<(), Box<dyn Error>> {
let cmd = Finder::get_selecting_command(commands, selecting_cmd);
let cmd_with_mode = if execute {
format!("{} {}", "run", cmd)
} else {
format!("{} {}", "copy", cmd)
};
let mut file = OpenOptions::new()
.write(true)
.create(true)
.open("/tmp/rcf.cmd")?;
file.set_len(0)?;
file.write_all(cmd_with_mode.as_bytes())?;
Ok(())
}
fn output_matched_commands(
matches: &Vec<&Command>,
selecting_cmd: usize,
stdout: &mut RawTerminal<Stdout>,
) -> Result<(), Box<dyn Error>> {
let (n_term_cols, _) = termion::terminal_size()?;
for (idx, c) in matches.into_iter().enumerate() {
if idx == selecting_cmd {
write!(
stdout,
"{}{}",
termion::color::Bg(termion::color::White),
termion::color::Fg(termion::color::Black)
)?;
} else {
write!(
stdout,
"{}{}",
termion::color::Bg(termion::color::Black),
termion::color::Fg(termion::color::White)
)?;
};
write!(stdout, "{}\r\n", c.truncate_command(n_term_cols - 5))?;
}
stdout.flush()?;
Ok(())
}
}