commit 334bd88b03c7c89627dd3932b79174b28832eb17 Author: iff Date: Sun Jul 30 18:40:18 2023 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5257a77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock +rule_parser/target diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..218e203 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1 @@ +hard_tabs = true diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d96cbb9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "pay_respect" +version = "0.1.0" +edition = "2021" + +[dependencies] +colored = "2.0" +rule_parser = { path = "rule_parser" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..ebe8f49 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Pay Respect + +Typed a wrong command? Pay Respect will try to correct your wrong console command simply by pressing `F`! + +## How to Pay Respect + +The binary is named `pay-respect`, by adding an alias to your shell +configuration: +``` shell +alias f="pay_respect" +``` +You can now **press `F` to Pay Respect**! + +## Current Progress + +Currently, only correction to `sudo` permission is implemented. + diff --git a/rule_parser/Cargo.toml b/rule_parser/Cargo.toml new file mode 100644 index 0000000..f44f0d4 --- /dev/null +++ b/rule_parser/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rule_parser" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +proc-macro = true + +[dependencies] +syn = "1.0" +quote = "1.0" +toml = "0.7" +serde = { version = "1.0", features = ["derive"] } diff --git a/rule_parser/src/lib.rs b/rule_parser/src/lib.rs new file mode 100644 index 0000000..f689bb1 --- /dev/null +++ b/rule_parser/src/lib.rs @@ -0,0 +1,60 @@ +use std::path::Path; + +use proc_macro::TokenStream; + +#[proc_macro] +pub fn parse_rules(input: TokenStream) -> TokenStream { + let directory = input.to_string().trim_matches('"').to_owned(); + let rules = get_rules(directory); + let string_hashmap = gen_string_hashmap(rules); + + string_hashmap.parse().unwrap() +} + +#[derive(serde::Deserialize)] +struct Rule { + command: String, + match_output: Vec, +} + +#[derive(serde::Deserialize)] +struct MatchOutput { + pattern: Vec, + suggest: String, +} + +fn get_rules(directory: String) -> Vec { + let files = std::fs::read_dir(directory).expect("Failed to read directory."); + + let mut rules = Vec::new(); + for file in files { + let file = file.expect("Failed to read file."); + let path = file.path(); + let path = path.to_str().expect("Failed to convert path to string."); + + let rule_file = parse_file(Path::new(path)); + rules.push(rule_file); + } + rules +} + +fn gen_string_hashmap(rules: Vec) -> String { + let mut string_hashmap = String::from("HashMap::from(["); + for rule in rules { + let command = rule.command.to_owned(); + string_hashmap.push_str(&format!("(\"{}\", vec![", command)); + for match_output in rule.match_output { + let pattern = match_output.pattern; + let suggest = match_output.suggest; + string_hashmap.push_str(&format!("(vec![\"{}\"], \"{}\"),", pattern.join("\", \""), suggest)); + } + string_hashmap.push_str("]),"); + } + string_hashmap.push_str("])"); + string_hashmap +} + +fn parse_file(file: &Path) -> Rule { + let file = std::fs::read_to_string(file).expect("Failed to read file."); + toml::from_str(&file).expect("Failed to parse toml.") +} diff --git a/rules/sudo.toml b/rules/sudo.toml new file mode 100644 index 0000000..be9a052 --- /dev/null +++ b/rules/sudo.toml @@ -0,0 +1,6 @@ +command = "sudo" + +[[match_output]] +pattern = [ "permission denied" ] +suggest = 'sudo {{command}}' + diff --git a/src/corrections.rs b/src/corrections.rs new file mode 100644 index 0000000..9dbf367 --- /dev/null +++ b/src/corrections.rs @@ -0,0 +1,93 @@ +use std::collections::HashMap; + +use rule_parser::parse_rules; + +use crate::shell::{command_output, find_last_command, find_shell}; +use crate::style::highlight_difference; + +pub fn correct_command() -> Option { + let shell = find_shell(); + let last_command = find_last_command(&shell); + let command_output = command_output(&shell, &last_command); + println!("Last command: {}", last_command); + println!("Command output: {}", command_output); + + let split_command = last_command.split_whitespace().collect::>(); + let command = match split_command.first().expect("No command found.") { + &"sudo" => split_command.get(1).expect("No command found."), + _ => split_command.first().expect("No command found."), + }; + + if split_command[0] != "sudo" { + let suggest = match_pattern("sudo", &command_output); + if let Some(suggest) = suggest { + let suggest = eval_suggest(&suggest, &last_command); + return Some(highlight_difference(&suggest, &last_command)); + } + } + + let suggest = match_pattern(command, &command_output); + if let Some(suggest) = suggest { + let suggest = eval_suggest(&suggest, &last_command); + return Some(highlight_difference(&suggest, &last_command)); + } + None +} + +fn match_pattern(command: &str, error_msg: &str) -> Option { + let rules = parse_rules!("rules"); + if rules.contains_key(command) { + let suggest = rules.get(command).unwrap(); + for (pattern, suggest) in suggest { + for pattern in pattern { + if error_msg.contains(pattern) { + return Some(suggest.to_owned().to_string()); + } + } + } + None + } else { + None + } +} + +fn eval_suggest(suggest: &str, last_command: &str) -> String { + let mut suggest = suggest.to_owned(); + if suggest.contains("{{command}}") { + suggest = suggest.replace("{{command}}", last_command); + } + while suggest.contains("{{command") { + let placeholder_start = "{{command"; + let placeholder_end = "}}"; + let placeholder = suggest.find(placeholder_start).unwrap() + ..suggest.find(placeholder_end).unwrap() + placeholder_end.len(); + + let range = suggest[placeholder.to_owned()].trim_matches(|c| c == '[' || c == ']'); + if let Some((start, end)) = range.split_once(':') { + let start = start.parse::().unwrap(); + let end = end.parse::().unwrap(); + let split_command = last_command.split_whitespace().collect::>(); + let command = split_command[start..end].join(" "); + suggest = suggest.replace(&suggest[placeholder], &command); + } else { + let range = range.parse::().unwrap(); + let split_command = last_command.split_whitespace().collect::>(); + let command = split_command[range].to_owned(); + suggest = suggest.replace(&suggest[placeholder], &command); + } + } + + suggest +} + +pub fn confirm_correction(command: &str) { + println!("Did you mean {}?", command); + println!("Press enter to execute the corrected command. Or press Ctrl+C to exit."); + std::io::stdin().read_line(&mut String::new()).unwrap(); + let shell = find_shell(); + std::process::Command::new(shell) + .arg("-c") + .arg(command) + .spawn() + .expect("failed to execute process"); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c83a913 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,14 @@ +mod corrections; +mod shell; +mod style; + +fn main() { + std::env::set_var("LC_ALL", "C"); + + let corrected_command = corrections::correct_command(); + if let Some(corrected_command) = corrected_command { + corrections::confirm_correction(&corrected_command); + } else { + println!("No correction found."); + } +} diff --git a/src/shell.rs b/src/shell.rs new file mode 100644 index 0000000..f70b024 --- /dev/null +++ b/src/shell.rs @@ -0,0 +1,73 @@ +use std::{collections::HashMap, fs::read_to_string, process::exit}; + +pub fn find_shell() -> String { + std::env::var("SHELL") + .unwrap_or_else(|_| String::from("bash")) + .rsplit('/') + .next() + .unwrap() + .to_string() + .to_lowercase() +} + +pub fn find_last_command(shell: &str) -> String { + let history_env = std::env::var("HISTFILE"); + let history_file = match history_env { + Ok(file) => file, + Err(_) => shell_default_history_file(shell), + }; + + let history = read_to_string(history_file).expect("Could not read history file."); + + match shell { + "bash" => history.lines().rev().nth(1).unwrap().to_string(), + "zsh" => history + .lines() + .rev() + .nth(1) + .unwrap() + .split_once(';') + .unwrap() + .1 + .to_string(), + "fish" => { + let mut history_lines = history.lines().rev(); + let mut last_command = String::new(); + let mut skips = 0; + while skips <= 2 { + last_command = history_lines.next().unwrap().to_string(); + if last_command.starts_with("- cmd") { + skips += 1; + } + } + last_command.split_once(": ").unwrap().1.to_string() + } + _ => { + println!("Unsupported shell."); + exit(1); + } + } +} + +pub fn command_output(shell: &str, command: &str) -> String { + let output = std::process::Command::new(shell) + .arg("-c") + .arg(command) + .output() + .expect("failed to execute process"); + + String::from_utf8_lossy(&output.stderr) + .to_string() + .to_lowercase() +} + +fn shell_default_history_file(shell: &str) -> String { + let shell_file_map = HashMap::from([ + ("bash", String::from(".bash_history")), + ("zsh", String::from(".zsh_history")), + ("fish", String::from(".local/share/fish/fish_history")), + ]); + + let file = shell_file_map.get(shell).expect("Unsupported shell."); + format!("{}/{}", std::env::var("HOME").unwrap(), file) +} diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..b94a84e --- /dev/null +++ b/src/style.rs @@ -0,0 +1,27 @@ +use colored::*; + +pub fn highlight_difference(corrected_command: &str, last_command: &str) -> String { + let mut highlighted_command = String::new(); + + let split_corrected_command = corrected_command.split(' '); + let split_last_command = last_command.split(' '); + + for new in split_corrected_command { + let mut changed = true; + for old in split_last_command.clone() { + if new == old { + changed = false; + break; + } + } + if changed { + highlighted_command.push_str(&new.red().bold()); + } else { + highlighted_command.push_str(&new.green()); + } + highlighted_command.push(' '); + } + + highlighted_command.pop(); + highlighted_command +}