Skip to main content

rgx/filter/
mod.rs

1//! `rgx filter` subcommand — live/non-interactive regex filter over stdin or a file.
2
3use std::io::{self, BufRead, BufReader, IsTerminal, Read, Write};
4use std::path::Path;
5
6use crate::config::cli::FilterArgs;
7use crate::engine::{self, EngineFlags, EngineKind};
8
9pub mod app;
10pub mod run;
11pub mod ui;
12pub use app::{FilterApp, Outcome};
13
14#[derive(Debug, Clone, Copy, Default)]
15pub struct FilterOptions {
16    pub invert: bool,
17    pub case_insensitive: bool,
18}
19
20impl FilterOptions {
21    fn flags(&self) -> EngineFlags {
22        EngineFlags {
23            case_insensitive: self.case_insensitive,
24            ..EngineFlags::default()
25        }
26    }
27}
28
29/// Apply the pattern to each line. Returns the 0-indexed line numbers of every
30/// line whose match status (matches vs. invert) satisfies `options.invert`.
31///
32/// Returns `Err` if the pattern fails to compile. An empty pattern is treated
33/// as "match everything" (every line passes) so the TUI has a sensible default
34/// before the user types.
35pub fn filter_lines(
36    lines: &[String],
37    pattern: &str,
38    options: FilterOptions,
39) -> Result<Vec<usize>, String> {
40    if pattern.is_empty() {
41        // Empty pattern — every line passes iff not inverted.
42        return Ok(if options.invert {
43            Vec::new()
44        } else {
45            (0..lines.len()).collect()
46        });
47    }
48
49    let engine = engine::create_engine(EngineKind::RustRegex);
50    let compiled = engine
51        .compile(pattern, &options.flags())
52        .map_err(|e| e.to_string())?;
53
54    let mut indices = Vec::with_capacity(lines.len());
55    for (idx, line) in lines.iter().enumerate() {
56        let matched = compiled
57            .find_matches(line)
58            .map(|v| !v.is_empty())
59            .unwrap_or(false);
60        if matched != options.invert {
61            indices.push(idx);
62        }
63    }
64    Ok(indices)
65}
66
67/// Exit codes, matching grep conventions.
68pub const EXIT_MATCH: i32 = 0;
69pub const EXIT_NO_MATCH: i32 = 1;
70pub const EXIT_ERROR: i32 = 2;
71
72/// Emit matching lines to `writer`. If `line_number` is true, each line is
73/// prefixed with its 1-indexed line number and a colon.
74pub fn emit_matches(
75    writer: &mut dyn Write,
76    lines: &[String],
77    matched: &[usize],
78    line_number: bool,
79) -> io::Result<()> {
80    for &idx in matched {
81        if line_number {
82            writeln!(writer, "{}:{}", idx + 1, lines[idx])?;
83        } else {
84            writeln!(writer, "{}", lines[idx])?;
85        }
86    }
87    Ok(())
88}
89
90/// Emit only the count of matched lines.
91pub fn emit_count(writer: &mut dyn Write, matched_count: usize) -> io::Result<()> {
92    writeln!(writer, "{matched_count}")
93}
94
95/// Read all lines from either a file path or the provided reader (typically stdin).
96/// Trailing `\n`/`\r\n` is stripped per line. A trailing empty line (from a
97/// terminating newline) is dropped.
98pub fn read_input(file: Option<&Path>, fallback: impl Read) -> io::Result<Vec<String>> {
99    let reader: Box<dyn BufRead> = match file {
100        Some(path) => Box::new(BufReader::new(std::fs::File::open(path)?)),
101        None => Box::new(BufReader::new(fallback)),
102    };
103    let mut out = Vec::new();
104    for line in reader.lines() {
105        out.push(line?);
106    }
107    Ok(out)
108}
109
110/// CLI entry point for `rgx filter`. Reads input, decides between non-interactive
111/// and TUI modes, and returns an exit code.
112pub fn entry(args: FilterArgs) -> i32 {
113    match run_entry(args) {
114        Ok(code) => code,
115        Err(msg) => {
116            eprintln!("rgx filter: {msg}");
117            EXIT_ERROR
118        }
119    }
120}
121
122fn run_entry(args: FilterArgs) -> Result<i32, String> {
123    let lines =
124        read_input(args.file.as_deref(), io::stdin()).map_err(|e| format!("reading input: {e}"))?;
125
126    let options = FilterOptions {
127        invert: args.invert,
128        case_insensitive: args.case_insensitive,
129    };
130
131    // Non-interactive paths: --count, --line-number, or a pattern was given and
132    // stdout is not a TTY (so we're being piped).
133    let has_pattern = args.pattern.as_deref().is_some_and(|p| !p.is_empty());
134    let stdout_is_tty = io::stdout().is_terminal();
135    let non_interactive = args.count || args.line_number || (has_pattern && !stdout_is_tty);
136
137    if non_interactive {
138        let pattern = args.pattern.unwrap_or_default();
139        let matched =
140            filter_lines(&lines, &pattern, options).map_err(|e| format!("pattern: {e}"))?;
141
142        let mut stdout = io::stdout().lock();
143        if args.count {
144            emit_count(&mut stdout, matched.len()).map_err(|e| format!("writing output: {e}"))?;
145        } else {
146            emit_matches(&mut stdout, &lines, &matched, args.line_number)
147                .map_err(|e| format!("writing output: {e}"))?;
148        }
149        return Ok(if matched.is_empty() {
150            EXIT_NO_MATCH
151        } else {
152            EXIT_MATCH
153        });
154    }
155
156    // TUI mode.
157    let initial_pattern = args.pattern.unwrap_or_default();
158    let app = FilterApp::new(lines, &initial_pattern, options);
159    let (final_app, outcome) = run::run_tui(app).map_err(|e| format!("tui: {e}"))?;
160
161    match outcome {
162        Outcome::Emit => {
163            let mut stdout = io::stdout().lock();
164            emit_matches(&mut stdout, &final_app.lines, &final_app.matched, false)
165                .map_err(|e| format!("writing output: {e}"))?;
166            Ok(if final_app.matched.is_empty() {
167                EXIT_NO_MATCH
168            } else {
169                EXIT_MATCH
170            })
171        }
172        Outcome::Discard => Ok(EXIT_NO_MATCH),
173        Outcome::Pending => Ok(EXIT_ERROR),
174    }
175}