From d0942a32537da9a1b84fdf3e74505f65e8efbec9 Mon Sep 17 00:00:00 2001 From: sabbellasri Date: Thu, 5 Mar 2026 22:22:37 -0500 Subject: [PATCH] Add regex filtering support (Fixes #554) --- Cargo.toml | 2 +- src/main.rs | 15 ++++- src/opt.rs | 12 ++++ src/util.rs | 21 +++++++ src/view.rs | 41 ++++++++++++- src/watcher.rs | 152 +++++++++++++++++++++++++++++++++++++++---------- 6 files changed, 209 insertions(+), 34 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7e61552ab..0e3cf2b6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ termbg = "0.6.2" tokio = { version = "1.50", optional = true, features = ["rt"] } toml = "1.0" unicode-width = "0.2" +regex = "1.12" [build-dependencies] anyhow = "1.0" @@ -61,7 +62,6 @@ clap_complete = "4.4" [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] pager = "0.16.1" procfs = "0.18.0" -regex = "1.12" uzers = "0.12" which = "8" diff --git a/src/main.rs b/src/main.rs index 771efa047..4f0d1d494 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use crate::column::Column; use crate::columns::*; use crate::config::*; use crate::opt::*; -use crate::util::{adjust, get_theme, lap}; +use crate::util::{adjust, get_theme, has_regex_syntax, lap}; use crate::view::View; use crate::watcher::Watcher; use anyhow::{Context, Error}; @@ -115,6 +115,7 @@ fn main() { fn run() -> Result<(), Error> { let mut opt: Opt = Parser::parse(); opt.watch_mode = opt.watch || opt.watch_interval.is_some(); + validate_search_args(&opt)?; if opt.gen_config { run_gen_config() @@ -183,7 +184,7 @@ fn run_default(opt: &mut Opt, config: &Config) -> Result<(), Error> { lap(&mut time, "Info: View::new"); } - view.filter(opt, config, 1); + view.filter(opt, config, 1)?; if opt.debug { lap(&mut time, "Info: view.filter"); @@ -204,6 +205,16 @@ fn run_default(opt: &mut Opt, config: &Config) -> Result<(), Error> { Ok(()) } +fn validate_search_args(opt: &Opt) -> Result<(), Error> { + if opt.regex && opt.keyword.len() > 1 { + anyhow::bail!("--regex accepts a single PATTERN argument"); + } + if opt.smart && opt.keyword.len() > 1 && opt.keyword.iter().any(|k| has_regex_syntax(k)) { + anyhow::bail!("--smart supports a single PATTERN when regex syntax is detected"); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/opt.rs b/src/opt.rs index 0de2d4913..86deb9b2c 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -54,6 +54,18 @@ pub struct Opt { #[clap(action, name = "KEYWORD")] pub keyword: Vec, + /// Use plain text search mode (default) + #[clap(long = "text", conflicts_with_all(&["regex", "smart"]))] + pub text: bool, + + /// Use regex search mode (single PATTERN) + #[clap(long = "regex", conflicts_with_all(&["text", "smart"]))] + pub regex: bool, + + /// Use smart mode (auto-detect regex syntax) + #[clap(long = "smart", conflicts_with_all(&["text", "regex"]))] + pub smart: bool, + /// AND logic for multi-keyword #[clap( short = 'a', diff --git a/src/util.rs b/src/util.rs index d3672eee3..3688d92c7 100644 --- a/src/util.rs +++ b/src/util.rs @@ -128,6 +128,27 @@ pub fn classify(keyword: &str) -> KeywordClass { } } +pub fn has_regex_syntax(pattern: &str) -> bool { + let mut escaped = false; + for c in pattern.chars() { + if escaped { + escaped = false; + continue; + } + if c == '\\' { + escaped = true; + continue; + } + if matches!( + c, + '|' | '(' | ')' | '[' | ']' | '{' | '}' | '*' | '+' | '?' | '^' | '$' + ) { + return true; + } + } + false +} + pub fn adjust(x: &str, len: usize, align: &ConfigColumnAlign) -> String { if len < UnicodeWidthStr::width(x) { String::from(truncate(x, len)) diff --git a/src/view.rs b/src/view.rs index a31da5a2d..c41badf9b 100644 --- a/src/view.rs +++ b/src/view.rs @@ -7,11 +7,13 @@ use crate::process::collect_proc; use crate::style::{apply_color, apply_style, color_to_column_style}; use crate::term_info::TermInfo; use crate::util::{ - KeywordClass, ansi_trim_end, classify, find_column_kind, find_exact, find_partial, truncate, + KeywordClass, ansi_trim_end, classify, find_column_kind, find_exact, find_partial, + has_regex_syntax, truncate, }; use anyhow::{Error, bail}; #[cfg(not(target_os = "windows"))] use pager::Pager; +use regex::RegexBuilder; use std::collections::HashMap; use std::time::Duration; @@ -219,15 +221,20 @@ impl View { }) } - pub fn filter(&mut self, opt: &Opt, config: &Config, header_lines: usize) { + pub fn filter(&mut self, opt: &Opt, config: &Config, header_lines: usize) -> Result<(), Error> { let mut cols_nonnumeric = Vec::new(); let mut cols_numeric = Vec::new(); + let mut cols_searchable = Vec::new(); for c in &self.columns { if c.nonnumeric_search { cols_nonnumeric.push(c.column.as_ref()); + cols_searchable.push(c.column.as_ref()); } if c.numeric_search { cols_numeric.push(c.column.as_ref()); + if !c.nonnumeric_search { + cols_searchable.push(c.column.as_ref()); + } } } @@ -241,6 +248,29 @@ impl View { } } + let regex_mode = if opt.regex { + true + } else if opt.smart { + opt.keyword.len() == 1 && has_regex_syntax(&opt.keyword[0]) + } else { + false + }; + + let regex = if regex_mode && !opt.keyword.is_empty() { + let pattern = &opt.keyword[0]; + let ignore_case = match config.search.case { + ConfigSearchCase::Smart => pattern == &pattern.to_ascii_lowercase(), + ConfigSearchCase::Insensitive => true, + ConfigSearchCase::Sensitive => false, + }; + let regex = RegexBuilder::new(pattern) + .case_insensitive(ignore_case) + .build()?; + Some(regex) + } else { + None + }; + let pids = self.columns[self.sort_info.idx] .column .sorted_pid(&self.sort_info.order); @@ -285,6 +315,8 @@ impl View { false } else if opt.keyword.is_empty() { true + } else if let Some(regex) = ®ex { + View::search_regex(*pid, cols_searchable.as_slice(), regex) } else { View::search( *pid, @@ -338,6 +370,7 @@ impl View { self.visible_pids = visible_pids; self.auxiliary_pids = auxiliary_pids; + Ok(()) } fn get_parent_pids(&self, pid: i32, parent_pids: &mut Vec) { @@ -681,6 +714,10 @@ impl View { } } + fn search_regex(pid: i32, cols: &[&dyn Column], regex: ®ex::Regex) -> bool { + cols.iter().any(|c| regex.is_match(&c.display_json(pid))) + } + #[cfg(not(any(target_os = "windows", any(target_os = "linux", target_os = "android"))))] fn pager(config: &Config) { if let Some(ref pager) = config.pager.command { diff --git a/src/watcher.rs b/src/watcher.rs index 830a3a50a..8d4535ae2 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -1,11 +1,12 @@ use crate::Opt; use crate::config::*; use crate::term_info::TermInfo; -use crate::util::get_theme; +use crate::util::{get_theme, has_regex_syntax}; use crate::view::View; use anyhow::Error; use chrono::offset::Local; use getch::Getch; +use regex::RegexBuilder; use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender, channel}; use std::thread; @@ -14,10 +15,7 @@ use std::time::Duration; enum Command { Wake, Sleep, - Next, - Prev, - Ascending, - Descending, + Key(u8), Quit, } @@ -29,28 +27,15 @@ impl Watcher { let getch = Getch::new(); loop { match getch.getch() { - Ok(x) if char::from(x) == 'q' => { - let _ = tx.send(Command::Quit); - break; - } - Ok(x) if char::from(x) == 'n' => { - let _ = tx.send(Command::Next); - } - Ok(x) if char::from(x) == 'p' => { - let _ = tx.send(Command::Prev); - } - Ok(x) if char::from(x) == 'a' => { - let _ = tx.send(Command::Ascending); - } - Ok(x) if char::from(x) == 'd' => { - let _ = tx.send(Command::Descending); - } // On windows, _getch return EXT(0x3) by Ctrl-C #[cfg(target_os = "windows")] Ok(x) if x == 3 => { let _ = tx.send(Command::Quit); break; } + Ok(x) => { + let _ = tx.send(Command::Key(x)); + } _ => (), } } @@ -69,7 +54,14 @@ impl Watcher { }); } - fn display_header(term_info: &TermInfo, opt: &Opt, interval: u64) -> Result { + fn display_header( + term_info: &TermInfo, + opt: &Opt, + interval: u64, + regex_editing: bool, + regex_buffer: &str, + regex_error: &Option, + ) -> Result { let header = if opt.tree { format!( " Interval: {}ms, Last Updated: {} ( Quit: q or Ctrl-C )", @@ -89,8 +81,31 @@ impl Watcher { console::style(header).white().bold().underlined() ))?; + if opt.regex || opt.smart { + let active = if regex_editing { + format!(" Editing regex: {}", regex_buffer) + } else { + let current = opt.keyword.first().cloned().unwrap_or_default(); + format!( + " Regex filter: {} ( Edit: /, Apply: Enter, Cancel: Esc )", + current + ) + }; + term_info.write_line(&active)?; + if let Some(err) = regex_error { + term_info.write_line(&format!(" Regex error: {err}"))?; + } + } + term_info.write_line("")?; - Ok(result.div_ceil(term_info.width)) + let mut lines = result.div_ceil(term_info.width) + 1; + if opt.regex || opt.smart { + lines += 1; + if regex_error.is_some() { + lines += 1; + } + } + Ok(lines) } pub fn start(opt: &mut Opt, config: &Config, interval: u64) -> Result<(), Error> { @@ -110,6 +125,9 @@ impl Watcher { let mut min_widths = HashMap::new(); let mut prev_term_width = 0; let mut prev_term_height = 0; + let mut regex_editing = false; + let mut regex_buffer = String::new(); + let mut regex_error: Option = None; 'outer: loop { let mut view = View::new(opt, config, true)?; @@ -124,9 +142,16 @@ impl Watcher { if resized { term_info.clear_screen()?; } - let header_lines = Watcher::display_header(&view.term_info, opt, interval)?; + let header_lines = Watcher::display_header( + &view.term_info, + opt, + interval, + regex_editing, + ®ex_buffer, + ®ex_error, + )?; - view.filter(opt, config, header_lines); + view.filter(opt, config, header_lines)?; view.adjust(config, &min_widths); for (i, c) in view.columns.iter().enumerate() { min_widths.insert(i, c.column.get_width()); @@ -153,10 +178,79 @@ impl Watcher { view.term_info.clear_screen()?; break 'outer; } - Command::Next => sort_idx = Some(view.inc_sort_column()), - Command::Prev => sort_idx = Some(view.dec_sort_column()), - Command::Ascending => sort_order = Some(ConfigSortOrder::Ascending), - Command::Descending => sort_order = Some(ConfigSortOrder::Descending), + Command::Key(x) => { + if !regex_editing { + match char::from(x) { + 'q' => { + tx_sleep.send(Command::Quit)?; + view.term_info.clear_screen()?; + break 'outer; + } + 'n' => sort_idx = Some(view.inc_sort_column()), + 'p' => sort_idx = Some(view.dec_sort_column()), + 'a' => sort_order = Some(ConfigSortOrder::Ascending), + 'd' => sort_order = Some(ConfigSortOrder::Descending), + '/' if opt.regex || opt.smart => { + regex_editing = true; + regex_buffer = opt.keyword.first().cloned().unwrap_or_default(); + } + _ => (), + } + } else { + match x { + 10 | 13 => { + let candidate = regex_buffer.clone(); + if candidate.is_empty() { + opt.keyword.clear(); + regex_error = None; + regex_editing = false; + } else { + let use_regex = opt.regex + || (opt.smart && has_regex_syntax(&candidate)); + if use_regex { + let ignore_case = match config.search.case { + ConfigSearchCase::Smart => { + candidate == candidate.to_ascii_lowercase() + } + ConfigSearchCase::Insensitive => true, + ConfigSearchCase::Sensitive => false, + }; + match RegexBuilder::new(&candidate) + .case_insensitive(ignore_case) + .build() + { + Ok(_) => { + opt.keyword = vec![candidate]; + regex_error = None; + regex_editing = false; + } + Err(e) => { + regex_error = Some(e.to_string()); + } + } + } else { + opt.keyword = vec![candidate]; + regex_error = None; + regex_editing = false; + } + } + } + 27 => { + regex_editing = false; + regex_error = None; + } + 8 | 127 => { + regex_buffer.pop(); + } + _ => { + let c = char::from(x); + if !c.is_control() { + regex_buffer.push(c); + } + } + } + } + } _ => (), } }