├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── src ├── errors.rs ├── expand.rs ├── main.rs ├── preview.rs └── write.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | # Generated by Cargo 3 | # will have compiled files and executables 4 | debug/ 5 | target/ 6 | 7 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 8 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 9 | Cargo.lock 10 | 11 | # These are backup files generated by rustfmt 12 | **/*.rs.bk 13 | 14 | # MSVC Windows builds of rustc generate these, which store debugging information 15 | *.pdb 16 | # Generated by Cargo 17 | # will have compiled files and executables 18 | debug/ 19 | target/ 20 | 21 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 22 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 23 | Cargo.lock 24 | 25 | # These are backup files generated by rustfmt 26 | **/*.rs.bk 27 | 28 | # MSVC Windows builds of rustc generate these, which store debugging information 29 | *.pdb 30 | # Generated by Cargo 31 | # will have compiled files and executables 32 | debug/ 33 | target/ 34 | 35 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 36 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 37 | Cargo.lock 38 | 39 | # These are backup files generated by rustfmt 40 | **/*.rs.bk 41 | 42 | # MSVC Windows builds of rustc generate these, which store debugging information 43 | *.pdb 44 | # Generated by Cargo 45 | # will have compiled files and executables 46 | debug/ 47 | target/ 48 | 49 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 50 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 51 | Cargo.lock 52 | 53 | # These are backup files generated by rustfmt 54 | **/*.rs.bk 55 | 56 | # MSVC Windows builds of rustc generate these, which store debugging information 57 | *.pdb 58 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "grug" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | clap = "3" 8 | difference = "2" 9 | fasthash = "0.4.0" 10 | regex = "1" 11 | rayon= "1" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2023 JacobTravers 2 | 3 | Permission is hereby granted, free 4 | of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grug 2 | 3 | Grug is a command-line tool for expanding, editing, diffing, and writing edits to files using vim-styled grep lines (such as `grep -RHn`, `ripgrep --vimgrep`, `ugrep -HknI`, etc). 4 | Grug is heavily inspired by the functionality and workflows of [kakoune-multi-file](https://github.com/natasky/kakoune-multi-file), and [kakoune-find](https://github.com/occivink/kakoune-find). 5 | 6 | ## TODO 7 | 8 | - [ ] adapt `--write` to apply hunk changes (e.g. edited output from `--expand`) 9 | - [ ] create `--preview` to view a diff of current file contents and the lines/hunks piped in 10 | - [ ] add tests 11 | 12 | ## Usage 13 | 14 | ``` 15 | grug [OPTIONS] 16 | 17 | -A, --above Include LINES_ABOVE lines above each line from stdin in the hunk 18 | -B, --below Include LINES_BELOW lines below each line from stdin in the hunk 19 | -C, --context Include CONTEXT_LINES above and below each line from stdin in the hunk 20 | -e, --expand Expand the lines from stdin into hunks 21 | -h, --help Print help information 22 | -w, --write Replace lines in files based on input from stdin 23 | ``` 24 | 25 | ## Examples 26 | 27 | To replace lines in files based on input from stdin: 28 | 29 | ``` 30 | echo "src/main.rs:10:new content" | grug --write 31 | ``` 32 | 33 | To expand lines from stdin into hunks: 34 | 35 | ``` 36 | echo "src/main.rs:10" | grug --expand 37 | ``` 38 | 39 | ## Installation 40 | 41 | ``` 42 | cargo install --git https://github.com/jtrv/grug 43 | ``` 44 | 45 | ## Kakoune 46 | 47 | In order to use this with kakoune you can add the following code to your kakrc 48 | 49 | ``` 50 | define-command grep-write -docstring " 51 | grep-write: pipes the current grep-buffer to grug -w and prints the results 52 | " %{ 53 | declare-option -hidden str grug_buf 54 | evaluate-commands -draft %{ 55 | evaluate-commands %sh{ 56 | echo "set-option buffer grug_buf '$(mktemp /tmp/grug_buf.XXX)'" 57 | } 58 | write -sync -force %opt{grug_buf} 59 | evaluate-commands %sh{ 60 | cat "$kak_opt_grug_buf" | grug -w | 61 | xargs -I{} echo "echo -debug 'grug: {}'; echo -markup {Information} 'grug: {}';" 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | ## License 68 | 69 | This project is licensed under the MIT License. 70 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use std::{error, fmt, io}; 2 | 3 | #[derive(Debug)] 4 | pub enum AppError { 5 | IoError(io::Error), 6 | ParseIntError(std::num::ParseIntError), 7 | InvalidNumber(String), 8 | InvalidLineFormat(String), 9 | InvalidLineNumber(String), 10 | } 11 | 12 | impl fmt::Display for AppError { 13 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 14 | match self { 15 | AppError::IoError(e) => write!(f, "IO error: {}", e), 16 | AppError::ParseIntError(e) => write!(f, "Parse int error: {}", e), 17 | AppError::InvalidNumber(s) => write!(f, "Invalid number: {}", s), 18 | AppError::InvalidLineFormat(s) => write!(f, "Invalid line format: {}", s), 19 | AppError::InvalidLineNumber(s) => write!(f, "Invalid line number: {}", s), 20 | } 21 | } 22 | } 23 | 24 | impl error::Error for AppError {} 25 | 26 | impl From for AppError { 27 | fn from(e: io::Error) -> Self { 28 | AppError::IoError(e) 29 | } 30 | } 31 | 32 | impl From for AppError { 33 | fn from(e: std::num::ParseIntError) -> Self { 34 | AppError::ParseIntError(e) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/expand.rs: -------------------------------------------------------------------------------- 1 | use clap::ArgMatches; 2 | use fasthash::{xx::Hash32 as XxHash32, FastHash}; 3 | use std::collections::HashMap; 4 | use std::fs::File; 5 | use std::io::{self, BufRead, BufReader}; 6 | use std::path::Path; 7 | 8 | use crate::errors::AppError; 9 | 10 | pub fn expand_to_hunks(matches: &ArgMatches) -> Result<(), AppError> { 11 | let context_lines: usize = matches 12 | .value_of("context") 13 | .unwrap_or("1") 14 | .parse() 15 | .map_err(|_| AppError::InvalidNumber("Invalid number of lines above".to_string()))?; 16 | 17 | let lines_above: usize = matches 18 | .value_of("above") 19 | .unwrap_or(&context_lines.to_string()) 20 | .parse() 21 | .map_err(|_| AppError::InvalidNumber("Invalid number of lines above".to_string()))?; 22 | 23 | let lines_below: usize = matches 24 | .value_of("below") 25 | .unwrap_or(&context_lines.to_string()) 26 | .parse() 27 | .map_err(|_| AppError::InvalidNumber("Invalid number of lines below".to_string()))?; 28 | 29 | let stdin = io::stdin(); 30 | let reader = BufReader::new(stdin.lock()); 31 | 32 | // Init HashMap to store files and their corresponding lines 33 | let mut file_lines: HashMap> = HashMap::new(); 34 | 35 | // Process each line from stdin into file_lines 36 | for line in reader.lines() { 37 | let line = line?; 38 | 39 | if line.trim().is_empty() { 40 | continue; 41 | } 42 | 43 | let parts: Vec<&str> = line.splitn(4, ':').collect(); 44 | 45 | // Validate format 46 | if parts.len() != 4 { 47 | return Err(AppError::InvalidLineFormat(line)); 48 | } 49 | 50 | // Extract file path and line number from parts 51 | let file_path = parts[0].to_string(); 52 | let line_number: usize = parts[1] 53 | .parse() 54 | .map_err(|_| AppError::InvalidLineNumber(parts[1].to_string()))?; 55 | 56 | file_lines.entry(file_path).or_default().push(line_number); 57 | } 58 | 59 | // Process each files' lines 60 | for (file_path, lines) in file_lines { 61 | let path = Path::new(&file_path); 62 | let file = File::open(path)?; 63 | let reader = BufReader::new(file); 64 | 65 | let file_lines: Vec = reader.lines().map(|l| l.unwrap()).collect(); 66 | let hunks = create_hunks(lines_above, lines_below, &lines, &file_lines, file_path)?; 67 | 68 | for hunk in hunks { 69 | println!("{}", hunk); 70 | } 71 | } 72 | Ok(()) 73 | } 74 | 75 | fn create_hunks( 76 | lines_above: usize, 77 | lines_below: usize, 78 | lines: &[usize], 79 | file_lines: &[String], 80 | file_path: String, 81 | ) -> Result, AppError> { 82 | let mut hunks: Vec = Vec::new(); 83 | let mut current_hunk: Vec = Vec::new(); 84 | let mut current_hunk_start = 0; 85 | 86 | for &line_number in lines { 87 | let start_line = if line_number > lines_above { 88 | line_number - lines_above 89 | } else { 90 | 1 91 | }; 92 | let end_line = std::cmp::min(line_number + lines_below, file_lines.len()); 93 | 94 | // Set the hunk start line and add lines to the hunk 95 | if current_hunk.is_empty() { 96 | current_hunk_start = start_line; 97 | current_hunk.extend(file_lines[start_line - 1..=end_line - 1].iter().cloned()); 98 | } else if start_line - current_hunk_start > current_hunk.len() { 99 | // Create hunk text and compute its hash 100 | let hunk_text = current_hunk.join("\n"); 101 | let hash = XxHash32::hash(hunk_text.as_bytes()); 102 | // Create a hunk header and add it to the hunks vector 103 | let hunk_header = format!( 104 | "@@@ {} {},{} {:x} @@@", 105 | file_path, 106 | current_hunk_start, 107 | current_hunk.len(), 108 | hash 109 | ); 110 | hunks.push(hunk_header); 111 | hunks.push(hunk_text); 112 | 113 | // Reset the current_hunk and start a new one 114 | current_hunk_start = start_line; 115 | current_hunk = file_lines[start_line - 1..=end_line - 1].to_vec(); 116 | } else { 117 | // Extend the current hunk with additional lines 118 | current_hunk.extend( 119 | file_lines[current_hunk.len() + current_hunk_start - 1..=end_line - 1] 120 | .iter() 121 | .cloned(), 122 | ); 123 | } 124 | } 125 | 126 | // Check if there's an unprocessed hunk 127 | if !current_hunk.is_empty() { 128 | let hunk_text = current_hunk.join("\n"); 129 | let hash = XxHash32::hash(hunk_text.as_bytes()); 130 | let hunk_header = format!( 131 | "@@@ {} {},{} {:x} @@@", 132 | file_path, 133 | current_hunk_start, 134 | current_hunk.len(), 135 | hash 136 | ); 137 | hunks.push(hunk_header); 138 | hunks.push(hunk_text); 139 | } 140 | 141 | Ok(hunks) 142 | } 143 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod errors; 2 | mod expand; 3 | /*mod preview;*/ 4 | mod write; 5 | 6 | use clap::{App, Arg}; 7 | 8 | use crate::errors::AppError; 9 | 10 | fn main() -> Result<(), AppError> { 11 | let matches = App::new("grug") 12 | .arg( 13 | Arg::new("expand") 14 | .short('e') 15 | .long("expand") 16 | .takes_value(false) 17 | .help("Expand the lines from stdin into hunks"), 18 | ) 19 | /*.arg( 20 | Arg::new("preview") 21 | .short('p') 22 | .long("preview") 23 | .takes_value(false) 24 | .help("Preview diffs from the hunks passed to stdin"), 25 | )*/ 26 | .arg( 27 | Arg::new("above") 28 | .short('A') 29 | .long("above") 30 | .takes_value(true) 31 | .value_name("LINES_ABOVE") 32 | .help("Include LINES_ABOVE lines above each line from stdin in the hunk"), 33 | ) 34 | .arg( 35 | Arg::new("below") 36 | .short('B') 37 | .long("below") 38 | .takes_value(true) 39 | .value_name("LINES_BELOW") 40 | .help("Include LINES_BELOW lines below each line from stdin in the hunk"), 41 | ) 42 | .arg( 43 | Arg::new("context") 44 | .short('C') 45 | .long("context") 46 | .takes_value(true) 47 | .value_name("CONTEXT_LINES") 48 | .help("Include CONTEXT_LINES above and below each line from stdin in the hunk"), 49 | ) 50 | .arg( 51 | Arg::new("write") 52 | .short('w') 53 | .long("write") 54 | .takes_value(false) 55 | .help("Replace lines in files based on input from stdin"), 56 | ) 57 | .get_matches(); 58 | 59 | if matches.is_present("expand") { 60 | expand::expand_to_hunks(&matches)?; 61 | /*} else if matches.is_present("preview") { 62 | preview::diff_hunks()?;*/ 63 | } else if matches.is_present("write") { 64 | write::write_changes(&matches)?; 65 | } else { 66 | eprintln!("Either --expand or --write flag must be provided."); 67 | } 68 | 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /src/preview.rs: -------------------------------------------------------------------------------- 1 | use fasthash::{xx::Hash32 as XxHash32, FastHash}; 2 | use regex::Regex; 3 | use std::fs::File; 4 | use std::io::{self, BufRead, BufReader}; 5 | use std::path::Path; 6 | 7 | use crate::errors::AppError; 8 | 9 | pub fn diff_hunks() -> Result<(), AppError> { 10 | let stdin = io::stdin(); 11 | let reader = BufReader::new(stdin.lock()); 12 | 13 | let re = Regex::new(r"@@@ (.*?) (\d+),(\d+) ([0-9a-f]+) @@@").unwrap(); 14 | 15 | let mut _current_file_path = String::new(); 16 | let mut current_file_lines: Option> = None; 17 | let mut current_hunk_lines: Vec = Vec::new(); 18 | let mut inside_hunk = false; 19 | 20 | for line in reader.lines() { 21 | let line = line?; 22 | 23 | if inside_hunk { 24 | if line.starts_with("@@@") { 25 | verify_and_print_hunk( 26 | &mut current_file_lines, 27 | ¤t_hunk_lines, 28 | )?; 29 | current_hunk_lines.clear(); 30 | inside_hunk = false; 31 | } else { 32 | current_hunk_lines.push(line.clone()); 33 | } 34 | } 35 | 36 | if !inside_hunk { 37 | if let Some(caps) = re.captures(&line) { 38 | _current_file_path = caps[1].to_string(); 39 | current_file_lines = Some(read_file_lines(&_current_file_path)?); 40 | inside_hunk = true; 41 | } 42 | } 43 | } 44 | 45 | if !current_hunk_lines.is_empty() { 46 | verify_and_print_hunk( 47 | &mut current_file_lines, 48 | ¤t_hunk_lines, 49 | )?; 50 | } 51 | 52 | Ok(()) 53 | } 54 | 55 | fn read_file_lines(file_path: &str) -> Result, AppError> { 56 | let path = Path::new(file_path); 57 | let file = File::open(path)?; 58 | let reader = BufReader::new(file); 59 | 60 | let lines: Vec = reader.lines().map(|l| l.unwrap()).collect(); 61 | Ok(lines) 62 | } 63 | 64 | fn verify_and_print_hunk( 65 | file_lines: &mut Option>, 66 | hunk_lines: &[String], 67 | ) -> Result<(), AppError> { 68 | if let Some(lines) = file_lines { 69 | let hunk_text = hunk_lines.join("\n"); 70 | let hunk_hash = XxHash32::hash(hunk_text.as_bytes()); 71 | 72 | let file_text = lines.join("\n"); 73 | let file_hash = XxHash32::hash(file_text.as_bytes()); 74 | 75 | if hunk_hash != file_hash { 76 | let diff = difference::Changeset::new(&file_text, &hunk_text, "\n"); 77 | println!("{}", hunk_lines[0]); // Print the hunk header 78 | print_diff(&diff); 79 | } 80 | } 81 | 82 | Ok(()) 83 | } 84 | 85 | fn print_diff(changeset: &difference::Changeset) { 86 | for diff in &changeset.diffs { 87 | match diff { 88 | difference::Difference::Same(_) => { 89 | // Do nothing, as we only want to print the changed lines 90 | } 91 | difference::Difference::Add(ref x) => { 92 | print!("+"); // Use '+' to represent added lines 93 | println!("{}", x); 94 | } 95 | difference::Difference::Rem(ref x) => { 96 | print!("-"); // Use '-' to represent removed lines 97 | println!("{}", x); 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/write.rs: -------------------------------------------------------------------------------- 1 | use clap::ArgMatches; 2 | use rayon::prelude::*; 3 | use std::collections::HashMap; 4 | use std::fs::File; 5 | use std::io::{self, BufRead, BufReader, Write}; 6 | use std::path::Path; 7 | 8 | pub fn write_changes(_matches: &ArgMatches) -> io::Result<()> { 9 | let file_changes = process_input()?; 10 | 11 | let actual_changes_and_ignores: Vec<_> = file_changes 12 | .into_par_iter() 13 | .filter_map(|(file_path, changes)| { 14 | replace_lines(&file_path, changes) 15 | .map_err(|e| eprintln!("Error replacing lines in {}: {}", file_path, e)) 16 | .ok() 17 | }) 18 | .collect(); 19 | 20 | let actual_changed_count: usize = actual_changes_and_ignores.iter().map(|(c, _)| c).sum(); 21 | let actual_ignored_count: usize = actual_changes_and_ignores.iter().map(|(_, i)| i).sum(); 22 | 23 | println!( 24 | "{} lines changed, {} lines ignored", 25 | actual_changed_count, actual_ignored_count 26 | ); 27 | Ok(()) 28 | } 29 | 30 | fn process_input() -> io::Result>> { 31 | let stdin = io::stdin(); 32 | let reader = BufReader::new(stdin.lock()); 33 | 34 | let mut file_changes: HashMap> = HashMap::new(); 35 | 36 | for line in reader.lines() { 37 | let line = line?; 38 | 39 | if line.trim().is_empty() { 40 | continue; 41 | } 42 | 43 | let parts: Vec<&str> = line.splitn(4, ':').collect(); 44 | 45 | if parts.len() != 4 { 46 | eprintln!("Invalid line format: {}", line); 47 | continue; 48 | } 49 | 50 | let file_path = parts[0].to_string(); 51 | let line_number: usize = match parts[1].parse() { 52 | Ok(num) => num, 53 | Err(_) => { 54 | eprintln!("Invalid line number: {}", parts[1]); 55 | continue; 56 | } 57 | }; 58 | 59 | let replacement = String::from(parts[3]); 60 | 61 | file_changes 62 | .entry(file_path) 63 | .or_default() 64 | .push(Change(line_number, replacement)); 65 | } 66 | 67 | Ok(file_changes) 68 | } 69 | 70 | struct Change(usize, String); 71 | 72 | fn replace_lines(file_path: &str, changes: Vec) -> io::Result<(usize, usize)> { 73 | let path = Path::new(file_path); 74 | let file = File::open(path)?; 75 | let reader = BufReader::new(file); 76 | 77 | let mut lines: Vec = reader.lines().collect::>()?; 78 | let mut changed_count = 0; 79 | let mut ignored_count = 0; 80 | 81 | for Change(line_number, replacement) in changes { 82 | if line_number == 0 || line_number > lines.len() { 83 | eprintln!( 84 | "Line number {} is out of range for file {}", 85 | line_number, file_path 86 | ); 87 | ignored_count += 1; 88 | continue; 89 | } 90 | 91 | if lines[line_number - 1] != replacement { 92 | lines[line_number - 1] = replacement; 93 | changed_count += 1; 94 | } else { 95 | ignored_count += 1; 96 | } 97 | } 98 | 99 | let mut file = File::create(path)?; 100 | for line in lines { 101 | writeln!(file, "{}", line)?; 102 | } 103 | 104 | Ok((changed_count, ignored_count)) 105 | } 106 | --------------------------------------------------------------------------------