navi/src/parser.rs

313 lines
10 KiB
Rust
Raw Normal View History

2020-03-04 21:01:23 +00:00
use crate::display;
use crate::filesystem;
use crate::structures::cheat::VariableMap;
2020-03-18 15:29:29 +00:00
use crate::structures::fnv::HashLine;
use crate::structures::fzf::{Opts as FzfOpts, SuggestionType};
2020-03-22 20:08:04 +00:00
use crate::structures::{
error::filesystem::{InvalidPath, UnreadableDir},
option::Config,
};
Initial cheat repo support (#258) This PR makes navi stop bundling `.cheat` files and makes it able to download from repos on GitHub, using a standardized config directory. - it fixes #233 and #237 - it makes #52, #256 and #257 much easier to implement - it makes #40 harder to implement - it's an alternate solution to #140 - it's influenced by #238 and #254. This PR ended up being much bigger than I would like so if you could review only this description it would be already very nice! When navi is started for the first time, a welcome cheatsheet is shown: ![Screenshot at 2020-03-15 10-20-04](https://user-images.githubusercontent.com/3226564/76702240-19fffd80-66a7-11ea-884f-97c565bc1ead.png) If the user selects the first option, the user is prompted to download `.cheat`s from https://github.com/denisidoro/cheats: ![Screenshot at 2020-03-15 10-20-35](https://user-images.githubusercontent.com/3226564/76702239-19fffd80-66a7-11ea-8f69-324f669b1e01.png) ![Screenshot at 2020-03-15 10-22-59](https://user-images.githubusercontent.com/3226564/76702236-18363a00-66a7-11ea-8ff4-53b497f85888.png) The config folder is populated: ![Screenshot at 2020-03-15 10-21-11](https://user-images.githubusercontent.com/3226564/76702238-19676700-66a7-11ea-8367-3e7b5733f2b4.png) When run again, cheats are available: ![Screenshot at 2020-03-15 10-21-34](https://user-images.githubusercontent.com/3226564/76702237-19676700-66a7-11ea-9c2a-d8829340f3e9.png) ### Breaking changes In order to make navi stop bundling shell widgets as well, a breaking change has been introduced: `source $(navi widget bash)` won't work anymore. It should be `source <(navi widget bash)` now. Any ideas on how to make this transition more graceful?
2020-03-15 16:46:58 +00:00
use crate::welcome;
2020-03-22 00:51:38 +00:00
use anyhow::{Context, Error};
2020-03-04 21:01:23 +00:00
use regex::Regex;
2020-03-18 15:29:29 +00:00
use std::collections::HashSet;
2020-03-04 21:01:23 +00:00
use std::fs;
use std::io::Write;
2020-03-21 02:22:11 +00:00
lazy_static! {
pub static ref VAR_LINE_REGEX: Regex =
Regex::new(r"^\$\s*([^:]+):(.*)").expect("Invalid regex");
}
2020-03-22 00:51:38 +00:00
fn parse_opts(text: &str) -> Result<FzfOpts, Error> {
2020-03-04 21:01:23 +00:00
let mut multi = false;
2020-03-17 15:39:38 +00:00
let mut prevent_extra = false;
let mut opts = FzfOpts::default();
2020-03-22 00:51:38 +00:00
let parts = shellwords::split(text)
.map_err(|_| anyhow!("Given options are missing a closing quote"))?;
parts
.into_iter()
.filter(|part| {
// We'll take parts in pairs of 2: (argument, value). Flags don't have a value tho, so we filter and handle them beforehand.
match part.as_str() {
"--multi" => {
multi = true;
false
}
"--prevent-extra" => {
prevent_extra = true;
false
}
_ => true,
2020-03-04 21:01:23 +00:00
}
2020-03-22 00:51:38 +00:00
})
.collect::<Vec<_>>()
.chunks(2)
.map(|flag_and_value| {
if let [flag, value] = flag_and_value {
match flag.as_str() {
"--headers" | "--header-lines" => {
opts.header_lines = value
.parse::<u8>()
.context("Value for `--headers` is invalid u8")?
}
"--column" => {
opts.column = Some(
value
.parse::<u8>()
.context("Value for `--column` is invalid u8")?,
)
}
"--delimiter" => opts.delimiter = Some(value.to_string()),
"--query" => opts.query = Some(value.to_string()),
"--filter" => opts.filter = Some(value.to_string()),
"--preview" => opts.preview = Some(value.to_string()),
"--preview-window" => opts.preview_window = Some(value.to_string()),
"--header" => opts.header = Some(value.to_string()),
"--overrides" => opts.overrides = Some(value.to_string()),
_ => (),
}
Ok(())
} else if let [flag] = flag_and_value {
Err(anyhow!("No value provided for the flag `{}`", flag))
2020-03-22 00:51:38 +00:00
} else {
unreachable!() // Chunking by 2 allows only for tuples of 1 or 2 items...
}
})
.collect::<Result<_, _>>()
.context("Failed to parse fzf options")?;
2020-03-04 21:01:23 +00:00
let suggestion_type = match (multi, prevent_extra) {
(true, _) => SuggestionType::MultipleSelections, // multi wins over allow-extra
(false, false) => SuggestionType::SingleRecommendation,
(false, true) => SuggestionType::SingleSelection,
};
opts.suggestion_type = suggestion_type;
2020-03-22 00:51:38 +00:00
Ok(opts)
2020-03-04 21:01:23 +00:00
}
2020-03-22 00:51:38 +00:00
fn parse_variable_line(line: &str) -> Result<(&str, &str, Option<FzfOpts>), Error> {
let caps = VAR_LINE_REGEX.captures(line).ok_or_else(|| {
anyhow!(
"No variables, command, and options found in the line `{}`",
2020-03-22 00:51:38 +00:00
line
)
})?;
let variable = caps
.get(1)
.ok_or_else(|| anyhow!("No variable captured in the line `{}`", line))?
2020-03-22 00:51:38 +00:00
.as_str()
.trim();
let mut command_plus_opts = caps
.get(2)
.ok_or_else(|| anyhow!("No command and options captured in the line `{}`", line))?
2020-03-22 00:51:38 +00:00
.as_str()
.split("---");
let command = command_plus_opts
.next()
.ok_or_else(|| anyhow!("No command captured in the line `{}`", line))?;
2020-03-22 00:51:38 +00:00
let command_options = command_plus_opts.next().map(parse_opts).transpose()?;
Ok((variable, command, command_options))
2020-03-04 21:01:23 +00:00
}
fn write_cmd(
tags: &str,
comment: &str,
snippet: &str,
tag_width: usize,
comment_width: usize,
stdin: &mut std::process::ChildStdin,
2020-03-22 00:51:38 +00:00
) -> Result<(), Error> {
if snippet.is_empty() {
2020-03-22 00:51:38 +00:00
Ok(())
} else {
stdin
.write_all(
2020-03-18 11:38:13 +00:00
display::format_line(&tags, &comment, &snippet, tag_width, comment_width)
.as_bytes(),
)
2020-03-22 00:51:38 +00:00
.context("Failed to write command to fzf's stdin")
}
}
2020-03-04 21:01:23 +00:00
fn read_file(
path: &str,
2020-03-18 11:38:13 +00:00
variables: &mut VariableMap,
2020-03-18 15:29:29 +00:00
visited_lines: &mut HashSet<u64>,
2020-03-04 21:01:23 +00:00
stdin: &mut std::process::ChildStdin,
2020-03-22 00:51:38 +00:00
) -> Result<(), Error> {
2020-03-04 21:01:23 +00:00
let mut tags = String::from("");
let mut comment = String::from("");
let mut snippet = String::from("");
2020-03-18 11:38:13 +00:00
let mut should_break = false;
2020-03-04 21:01:23 +00:00
2020-03-07 21:03:51 +00:00
let (tag_width, comment_width) = *display::WIDTHS;
2020-03-04 21:01:23 +00:00
2020-03-22 00:51:38 +00:00
for (line_nr, line) in filesystem::read_lines(path)?.into_iter().enumerate() {
if should_break {
break;
}
2020-03-22 00:51:38 +00:00
// duplicate
if !tags.is_empty() && !comment.is_empty() {}
2020-03-04 21:01:23 +00:00
2020-03-22 00:51:38 +00:00
// blank
if line.is_empty() {
}
// tag
else if line.starts_with('%') {
if write_cmd(&tags, &comment, &snippet, tag_width, comment_width, stdin).is_err() {
should_break = true
2020-03-04 21:01:23 +00:00
}
2020-03-22 00:51:38 +00:00
snippet = String::from("");
tags = String::from(&line[2..]);
}
// metacomment
else if line.starts_with(';') {
}
// comment
else if line.starts_with('#') {
if write_cmd(&tags, &comment, &snippet, tag_width, comment_width, stdin).is_err() {
should_break = true
}
2020-03-22 00:51:38 +00:00
snippet = String::from("");
comment = String::from(&line[2..]);
}
// variable
else if line.starts_with('$') {
if write_cmd(&tags, &comment, &snippet, tag_width, comment_width, stdin).is_err() {
should_break = true
2020-03-04 21:01:23 +00:00
}
2020-03-22 00:51:38 +00:00
snippet = String::from("");
let (variable, command, opts) = parse_variable_line(&line).with_context(|| {
format!(
"Failed to parse variable line. See line nr.{} in cheatsheet `{}`",
2020-03-22 00:51:38 +00:00
line_nr + 1,
path
)
})?;
variables.insert(&tags, &variable, (String::from(command), opts));
}
// snippet
else {
let hash = format!("{}{}", &comment, &line).hash_line();
if visited_lines.contains(&hash) {
continue;
2020-03-04 21:01:23 +00:00
}
2020-03-22 00:51:38 +00:00
visited_lines.insert(hash);
2020-03-22 00:51:38 +00:00
if !(&snippet).is_empty() {
snippet.push_str(display::LINE_SEPARATOR);
2020-03-04 21:01:23 +00:00
}
2020-03-22 00:51:38 +00:00
snippet.push_str(&line);
2020-03-04 21:01:23 +00:00
}
2020-03-22 00:51:38 +00:00
}
Initial cheat repo support (#258) This PR makes navi stop bundling `.cheat` files and makes it able to download from repos on GitHub, using a standardized config directory. - it fixes #233 and #237 - it makes #52, #256 and #257 much easier to implement - it makes #40 harder to implement - it's an alternate solution to #140 - it's influenced by #238 and #254. This PR ended up being much bigger than I would like so if you could review only this description it would be already very nice! When navi is started for the first time, a welcome cheatsheet is shown: ![Screenshot at 2020-03-15 10-20-04](https://user-images.githubusercontent.com/3226564/76702240-19fffd80-66a7-11ea-884f-97c565bc1ead.png) If the user selects the first option, the user is prompted to download `.cheat`s from https://github.com/denisidoro/cheats: ![Screenshot at 2020-03-15 10-20-35](https://user-images.githubusercontent.com/3226564/76702239-19fffd80-66a7-11ea-8f69-324f669b1e01.png) ![Screenshot at 2020-03-15 10-22-59](https://user-images.githubusercontent.com/3226564/76702236-18363a00-66a7-11ea-8ff4-53b497f85888.png) The config folder is populated: ![Screenshot at 2020-03-15 10-21-11](https://user-images.githubusercontent.com/3226564/76702238-19676700-66a7-11ea-8367-3e7b5733f2b4.png) When run again, cheats are available: ![Screenshot at 2020-03-15 10-21-34](https://user-images.githubusercontent.com/3226564/76702237-19676700-66a7-11ea-9c2a-d8829340f3e9.png) ### Breaking changes In order to make navi stop bundling shell widgets as well, a breaking change has been introduced: `source $(navi widget bash)` won't work anymore. It should be `source <(navi widget bash)` now. Any ideas on how to make this transition more graceful?
2020-03-15 16:46:58 +00:00
2020-03-22 00:51:38 +00:00
if !should_break {
let _ = write_cmd(&tags, &comment, &snippet, tag_width, comment_width, stdin);
2020-03-04 21:01:23 +00:00
}
2020-03-22 00:51:38 +00:00
Ok(())
2020-03-04 21:01:23 +00:00
}
fn paths_from_path_param<'a>(env_var: &'a str) -> impl Iterator<Item = &'a str> + 'a {
env_var.split(':').filter(|folder| folder != &"")
}
pub fn read_all(
config: &Config,
stdin: &mut std::process::ChildStdin,
) -> Result<VariableMap, Error> {
2020-03-18 11:38:13 +00:00
let mut variables = VariableMap::new();
Initial cheat repo support (#258) This PR makes navi stop bundling `.cheat` files and makes it able to download from repos on GitHub, using a standardized config directory. - it fixes #233 and #237 - it makes #52, #256 and #257 much easier to implement - it makes #40 harder to implement - it's an alternate solution to #140 - it's influenced by #238 and #254. This PR ended up being much bigger than I would like so if you could review only this description it would be already very nice! When navi is started for the first time, a welcome cheatsheet is shown: ![Screenshot at 2020-03-15 10-20-04](https://user-images.githubusercontent.com/3226564/76702240-19fffd80-66a7-11ea-884f-97c565bc1ead.png) If the user selects the first option, the user is prompted to download `.cheat`s from https://github.com/denisidoro/cheats: ![Screenshot at 2020-03-15 10-20-35](https://user-images.githubusercontent.com/3226564/76702239-19fffd80-66a7-11ea-8f69-324f669b1e01.png) ![Screenshot at 2020-03-15 10-22-59](https://user-images.githubusercontent.com/3226564/76702236-18363a00-66a7-11ea-8ff4-53b497f85888.png) The config folder is populated: ![Screenshot at 2020-03-15 10-21-11](https://user-images.githubusercontent.com/3226564/76702238-19676700-66a7-11ea-8367-3e7b5733f2b4.png) When run again, cheats are available: ![Screenshot at 2020-03-15 10-21-34](https://user-images.githubusercontent.com/3226564/76702237-19676700-66a7-11ea-9c2a-d8829340f3e9.png) ### Breaking changes In order to make navi stop bundling shell widgets as well, a breaking change has been introduced: `source $(navi widget bash)` won't work anymore. It should be `source <(navi widget bash)` now. Any ideas on how to make this transition more graceful?
2020-03-15 16:46:58 +00:00
let mut found_something = false;
2020-03-18 15:29:29 +00:00
let mut visited_lines = HashSet::new();
let paths = filesystem::cheat_paths(config)?;
let folders = paths_from_path_param(&paths);
2020-03-04 21:01:23 +00:00
for folder in folders {
2020-03-22 20:08:04 +00:00
let dir_entries = fs::read_dir(folder).map_err(|e| UnreadableDir::new(folder, e))?;
2020-03-22 00:51:38 +00:00
for entry in dir_entries {
2020-03-22 20:08:04 +00:00
let path = entry.map_err(|e| UnreadableDir::new(folder, e))?.path();
2020-03-22 00:51:38 +00:00
let path_str = path
.to_str()
2020-03-22 19:47:13 +00:00
.ok_or_else(|| InvalidPath(path.to_path_buf()))?;
2020-03-22 00:51:38 +00:00
if path_str.ends_with(".cheat")
&& read_file(path_str, &mut variables, &mut visited_lines, stdin).is_ok()
&& !found_something
{
found_something = true;
2020-03-04 21:01:23 +00:00
}
}
}
Initial cheat repo support (#258) This PR makes navi stop bundling `.cheat` files and makes it able to download from repos on GitHub, using a standardized config directory. - it fixes #233 and #237 - it makes #52, #256 and #257 much easier to implement - it makes #40 harder to implement - it's an alternate solution to #140 - it's influenced by #238 and #254. This PR ended up being much bigger than I would like so if you could review only this description it would be already very nice! When navi is started for the first time, a welcome cheatsheet is shown: ![Screenshot at 2020-03-15 10-20-04](https://user-images.githubusercontent.com/3226564/76702240-19fffd80-66a7-11ea-884f-97c565bc1ead.png) If the user selects the first option, the user is prompted to download `.cheat`s from https://github.com/denisidoro/cheats: ![Screenshot at 2020-03-15 10-20-35](https://user-images.githubusercontent.com/3226564/76702239-19fffd80-66a7-11ea-8f69-324f669b1e01.png) ![Screenshot at 2020-03-15 10-22-59](https://user-images.githubusercontent.com/3226564/76702236-18363a00-66a7-11ea-8ff4-53b497f85888.png) The config folder is populated: ![Screenshot at 2020-03-15 10-21-11](https://user-images.githubusercontent.com/3226564/76702238-19676700-66a7-11ea-8367-3e7b5733f2b4.png) When run again, cheats are available: ![Screenshot at 2020-03-15 10-21-34](https://user-images.githubusercontent.com/3226564/76702237-19676700-66a7-11ea-9c2a-d8829340f3e9.png) ### Breaking changes In order to make navi stop bundling shell widgets as well, a breaking change has been introduced: `source $(navi widget bash)` won't work anymore. It should be `source <(navi widget bash)` now. Any ideas on how to make this transition more graceful?
2020-03-15 16:46:58 +00:00
if !found_something {
welcome::cheatsheet(stdin);
}
Ok(variables)
2020-03-04 21:01:23 +00:00
}
2020-03-14 08:09:09 +00:00
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_variable_line() {
2020-03-14 12:33:07 +00:00
let (variable, command, command_options) =
2020-03-22 00:51:38 +00:00
parse_variable_line("$ user : echo -e \"$(whoami)\\nroot\" --- --allow-extra").unwrap();
2020-03-14 08:09:09 +00:00
assert_eq!(command, " echo -e \"$(whoami)\\nroot\" ");
assert_eq!(variable, "user");
2020-03-14 12:33:07 +00:00
assert_eq!(
command_options,
Some(FzfOpts {
2020-03-14 12:33:07 +00:00
header_lines: 0,
column: None,
delimiter: None,
suggestion_type: SuggestionType::SingleRecommendation,
..Default::default()
2020-03-14 12:33:07 +00:00
})
);
2020-03-14 08:09:09 +00:00
}
use std::process::{Command, Stdio};
#[test]
fn test_read_file() {
let path = "tests/cheats/ssh.cheat";
2020-03-18 11:38:13 +00:00
let mut variables = VariableMap::new();
2020-03-14 12:33:07 +00:00
let mut child = Command::new("cat").stdin(Stdio::piped()).spawn().unwrap();
2020-03-14 08:09:09 +00:00
let child_stdin = child.stdin.as_mut().unwrap();
2020-03-18 15:29:29 +00:00
let mut visited_lines: HashSet<u64> = HashSet::new();
2020-03-22 00:51:38 +00:00
read_file(path, &mut variables, &mut visited_lines, child_stdin).unwrap();
2020-03-18 15:29:29 +00:00
let expected_suggestion = (
2020-03-18 11:38:13 +00:00
r#" echo -e "$(whoami)\nroot" "#.to_string(),
Some(FzfOpts {
2020-03-18 11:38:13 +00:00
header_lines: 0,
column: None,
delimiter: None,
suggestion_type: SuggestionType::SingleRecommendation,
..Default::default()
2020-03-18 11:38:13 +00:00
}),
);
2020-03-18 15:29:29 +00:00
let actual_suggestion = variables.get("ssh", "user");
assert_eq!(Some(&expected_suggestion), actual_suggestion);
2020-03-14 08:09:09 +00:00
}
#[test]
fn splitting_of_dirs_param_may_not_contain_empty_items() {
// Trailing colon indicates potential extra path. Split returns an empty item for it. This empty item should be filtered away, which is what this test checks.
let given_path_config = "SOME_PATH:ANOTHER_PATH:";
let found_paths = paths_from_path_param(given_path_config);
let mut expected_paths = vec!["SOME_PATH", "ANOTHER_PATH"].into_iter();
for found in found_paths {
let expected = expected_paths.next().unwrap();
assert_eq!(found, expected)
}
}
2020-03-14 08:09:09 +00:00
}