| use std::collections::BTreeMap; |
|
|
| use console::style; |
| use derive_setters::Setters; |
| use regex::Regex; |
|
|
| |
| #[derive(Clone, Setters)] |
| #[setters(into, strip_option)] |
| pub struct GrepFormat { |
| lines: Vec<String>, |
| regex: Option<Regex>, |
| } |
|
|
| |
| |
| #[derive(Debug)] |
| struct ParsedLine<'a> { |
| |
| path: &'a str, |
| |
| line_num: &'a str, |
| |
| content: &'a str, |
| } |
|
|
| impl<'a> ParsedLine<'a> { |
| |
| |
| |
| |
| |
| |
| |
| |
| fn parse(line: &'a str) -> Option<Self> { |
| let parts: Vec<_> = line.split(':').collect(); |
| if parts.len() != 3 { |
| return None; |
| } |
|
|
| let path = parts.first()?.trim(); |
| let line_num = parts.get(1)?.trim(); |
| let content = parts.get(2)?.trim(); |
|
|
| |
| |
| if path.is_empty() || line_num.is_empty() || !line_num.chars().all(|c| c.is_ascii_digit()) { |
| return None; |
| } |
|
|
| Some(Self { path, line_num, content }) |
| } |
| } |
|
|
| type Lines<'a> = Vec<(&'a str, &'a str)>; |
| impl GrepFormat { |
| |
| pub fn new(lines: Vec<String>) -> Self { |
| Self { lines, regex: None } |
| } |
|
|
| |
| fn collect_entries<'a>(&'a self) -> (BTreeMap<&'a str, Lines<'a>>, usize) { |
| self.lines |
| .iter() |
| .map(String::as_str) |
| .filter_map(ParsedLine::parse) |
| .fold((BTreeMap::new(), 0), |(mut entries, max_width), parsed| { |
| let new_width = max_width.max(parsed.line_num.len()); |
| entries |
| .entry(parsed.path) |
| .or_default() |
| .push((parsed.line_num, parsed.content)); |
| (entries, new_width) |
| }) |
| } |
|
|
| |
| fn format_line(&self, num: &str, content: &str, padding: usize) -> String { |
| let num = style(format!("{num:>padding$}: ")).dim(); |
|
|
| |
| let line = match self.regex { |
| Some(ref regex) => regex.find(content).map_or_else( |
| || content.to_string(), |
| |mat| { |
| format!( |
| "{}{}{}", |
| content.get(..mat.start()).unwrap_or(""), |
| style(content.get(mat.start()..mat.end()).unwrap_or("")) |
| .yellow() |
| .bold(), |
| content.get(mat.end()..).unwrap_or("") |
| ) |
| }, |
| ), |
| None => content.to_string(), |
| }; |
|
|
| format!("{num}{line}\n") |
| } |
|
|
| |
| fn format_file_group( |
| &self, |
| path: &str, |
| group: Vec<(&str, &str)>, |
| max_num_width: usize, |
| ) -> String { |
| let file_header = style(path).cyan(); |
| let formatted_lines = group |
| .into_iter() |
| .map(|(num, content)| self.format_line(num, content, max_num_width)) |
| .collect::<String>(); |
| format!("{file_header}\n{formatted_lines}") |
| } |
|
|
| |
| fn format_raw_paths(&self) -> String { |
| |
| let formatted_paths: Vec<_> = self |
| .lines |
| .iter() |
| .map(|line| format!("{}", style(line).cyan())) |
| .collect(); |
|
|
| |
| formatted_paths.join("\n") |
| } |
|
|
| |
| pub fn format(&self) -> String { |
| if self.lines.is_empty() { |
| return String::new(); |
| } |
|
|
| |
| let has_valid_entries = self |
| .lines |
| .iter() |
| .any(|line| ParsedLine::parse(line).is_some()); |
|
|
| |
| if !has_valid_entries { |
| return self.format_raw_paths(); |
| } |
|
|
| |
| let (entries, max_num_width) = self.collect_entries(); |
|
|
| |
| let formatted_entries: Vec<_> = entries |
| .into_iter() |
| .map(|(path, group)| self.format_file_group(path, group, max_num_width)) |
| .collect(); |
|
|
| |
| formatted_entries.join("\n") |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use std::fmt::{Display, Formatter}; |
|
|
| use insta::assert_snapshot; |
|
|
| use super::*; |
|
|
| |
| #[derive(Debug)] |
| struct GrepSpec { |
| description: String, |
| input: Vec<String>, |
| output: String, |
| } |
|
|
| impl GrepSpec { |
| |
| fn new(description: &str, input: Vec<&str>, pattern: Option<&str>) -> Self { |
| let input: Vec<String> = input.iter().map(|s| s.to_string()).collect(); |
|
|
| |
| let formatter = match pattern { |
| Some(pattern) => GrepFormat::new(input.clone()).regex(Regex::new(pattern).unwrap()), |
| None => GrepFormat::new(input.clone()), |
| }; |
|
|
| let output = strip_ansi_escapes::strip_str(formatter.format()).to_string(); |
|
|
| Self { description: description.to_string(), input, output } |
| } |
| } |
|
|
| impl Display for GrepSpec { |
| fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { |
| writeln!(f, "\n[{}]", self.description)?; |
| writeln!(f, "[RAW]")?; |
| writeln!(f, "{}", self.input.join("\n"))?; |
| writeln!(f, "[FMT]")?; |
| writeln!(f, "{}", self.output) |
| } |
| } |
|
|
| #[derive(Default, Debug)] |
| struct GrepSuite(Vec<GrepSpec>); |
|
|
| impl GrepSuite { |
| fn add(&mut self, description: &str, input: Vec<&str>, pattern: Option<&str>) { |
| self.0.push(GrepSpec::new(description, input, pattern)); |
| } |
| } |
|
|
| impl Display for GrepSuite { |
| fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { |
| for spec in &self.0 { |
| writeln!(f, "{spec}")?; |
| } |
| Ok(()) |
| } |
| } |
|
|
| #[test] |
| fn test_combined_grep_suite() { |
| let mut suite = GrepSuite::default(); |
|
|
| suite.add( |
| "Basic single file with two matches", |
| vec!["file.txt:1:first match", "file.txt:2:second match"], |
| Some("match"), |
| ); |
|
|
| suite.add( |
| "Multiple files with various matches", |
| vec![ |
| "file1.txt:1:match in file1", |
| "file2.txt:1:first match in file2", |
| "file2.txt:2:second match in file2", |
| "file3.txt:1:match in file3", |
| ], |
| Some("file"), |
| ); |
|
|
| suite.add( |
| "File with varying line number widths", |
| vec![ |
| "file.txt:1:first line", |
| "file.txt:5:fifth line", |
| "file.txt:10:tenth line", |
| "file.txt:100:hundredth line", |
| ], |
| Some("line"), |
| ); |
|
|
| suite.add( |
| "Mix of valid and invalid input lines", |
| vec![ |
| "file.txt:1:valid match", |
| "malformed line without separator", |
| "file.txt:2:another valid match", |
| ], |
| Some("match"), |
| ); |
|
|
| suite.add("Empty input vector", vec![], None); |
|
|
| suite.add( |
| "Input with special characters and formatting", |
| vec![ |
| "path/to/file.txt:1:contains 🦀 rust", |
| "path/to/file.txt:2:has\ttabs\tand\tspaces", |
| "path/to/file.txt:3:contains\nnewlines", |
| ], |
| Some("contains"), |
| ); |
|
|
| suite.add( |
| "Multiple files with same line numbers", |
| vec![ |
| "test1.rs:10:fn test1()", |
| "test2.rs:10:fn test2()", |
| "test3.rs:10:fn test3()", |
| ], |
| Some("fn"), |
| ); |
|
|
| suite.add( |
| "Content with full-width unicode characters", |
| vec![ |
| "test.txt:1:Contains 你好 characters", |
| "test.txt:2:More UTF-8 ありがとう here", |
| ], |
| Some("Contains"), |
| ); |
|
|
| |
| suite.add( |
| "Without regex - Basic single file with two matches", |
| vec!["file.txt:1:first match", "file.txt:2:second match"], |
| None, |
| ); |
|
|
| suite.add( |
| "Without regex - Multiple files with various patterns", |
| vec![ |
| "file1.txt:1:regex pattern in file1", |
| "file2.txt:1:another pattern in file2", |
| "file2.txt:2:different pattern in file2", |
| ], |
| None, |
| ); |
|
|
| assert_snapshot!(suite); |
| } |
|
|
| #[test] |
| fn test_with_and_without_regex() { |
| let lines = vec!["a/b/c.md".to_string(), "p/q/r.rs".to_string()]; |
|
|
| |
| let grep = GrepFormat::new(lines); |
| let output = strip_ansi_escapes::strip_str(grep.format()).to_string(); |
|
|
| assert!(output.contains("c.md")); |
| assert!(output.contains("r.rs")); |
| } |
| } |
|
|