Skip to main content

sel/
cli.rs

1//! Command-line argument parsing using clap.
2
3use clap::Parser;
4use std::io::IsTerminal;
5use std::path::PathBuf;
6
7/// `sel` — Select slices from text files by line numbers, ranges, positions, or regex.
8#[derive(Parser, Debug)]
9#[command(name = "sel")]
10#[command(author = "InkyQuill")]
11#[command(version = env!("CARGO_PKG_VERSION"))]
12#[command(about = "Select slices from text files", long_about = None)]
13#[command(
14    long_about = "Extract fragments from text files by line numbers, ranges, positions (line:column), or regex patterns.
15
16EXAMPLES:
17    sel 30-35 file.txt           Output lines 30-35
18    sel 10,15-20,22 file.txt     Output lines 10, 15-20, and 22
19    sel -c 3 42 file.txt         Show line 42 with 3 lines of context
20    sel -n 10 23:260 file.txt    Show position line 23, column 260 with char context
21    sel -e ERROR log.txt         Search for 'ERROR' pattern
22    sel file.txt                 Output entire file with line numbers (like cat -n)"
23)]
24pub struct Cli {
25    /// Show N lines of context before and after matches
26    #[arg(short = 'c', long = "context", value_name = "N")]
27    pub context: Option<usize>,
28
29    /// Show N characters of context around position
30    ///
31    /// Only works with positional selectors (L:C) or with -e.
32    #[arg(short = 'n', long = "char-context", value_name = "N")]
33    pub char_context: Option<usize>,
34
35    /// Don't output line numbers
36    ///
37    /// Filenames are still shown when processing multiple files.
38    #[arg(short = 'l', long = "no-line-numbers")]
39    pub no_line_numbers: bool,
40
41    /// Regular expression pattern (PCRE-like syntax)
42    ///
43    /// When using -e, the selector argument is ignored.
44    /// Multiple files can be specified with -e.
45    #[arg(short = 'e', long = "regex", value_name = "PATTERN")]
46    pub regex: Option<String>,
47
48    /// Invert the regex match: emit lines that do NOT match -e.
49    #[arg(short = 'v', long = "invert-match")]
50    pub invert: bool,
51
52    /// Always print filename prefix
53    ///
54    /// By default, filename is only shown when processing multiple files.
55    #[arg(short = 'H', long = "with-filename")]
56    pub with_filename: bool,
57
58    /// Color output [auto, always, never]
59    ///
60    /// Default is 'auto' (enabled when stdout is a terminal).
61    #[arg(long = "color", value_name = "WHEN")]
62    pub color: Option<String>,
63
64    /// Write output to FILE instead of stdout. Use `-` for stdout explicitly.
65    #[arg(short = 'o', long = "output", value_name = "FILE")]
66    pub output: Option<String>,
67
68    /// With `-o`, overwrite an existing file.
69    #[arg(long = "force")]
70    pub force: bool,
71
72    /// Selector and/or file(s)
73    ///
74    /// The first positional argument can be:
75    /// - A selector (line number, range, position) if it matches selector syntax
76    /// - A filename otherwise
77    ///
78    /// When using -e, all positional arguments are treated as files.
79    #[arg(value_name = "SELECTOR_OR_FILE")]
80    pub args: Vec<String>,
81}
82
83impl Cli {
84    /// Get the selector from arguments (only valid when not using -e).
85    pub fn get_selector(&self) -> Option<String> {
86        if self.regex.is_some() {
87            return None;
88        }
89
90        if self.args.is_empty() {
91            return None;
92        }
93
94        // Check if first arg looks like a selector
95        let first = &self.args[0];
96
97        // A selector is:
98        // - A single number (e.g., "42")
99        // - A range (e.g., "10-20")
100        // - A comma-separated list (e.g., "1,5,10-15")
101        // - A position (e.g., "23:260")
102        // - Contains only digits, commas, colons, and hyphens
103        if self.looks_like_selector(first) {
104            Some(first.clone())
105        } else {
106            None
107        }
108    }
109
110    /// Get the list of input files.
111    ///
112    /// Returns at least one entry — falls back to `-` (stdin) when no
113    /// explicit files are provided.
114    pub fn get_files(&self) -> Vec<PathBuf> {
115        if self.args.is_empty() {
116            return vec![PathBuf::from("-")];
117        }
118
119        // If using regex mode, all args are files
120        if self.regex.is_some() {
121            return self.args.iter().map(PathBuf::from).collect();
122        }
123
124        // If first arg is a selector, skip it
125        let start = if self.looks_like_selector(&self.args[0]) {
126            1
127        } else {
128            0
129        };
130
131        let files: Vec<_> = self.args[start..].iter().map(PathBuf::from).collect();
132        if files.is_empty() {
133            vec![PathBuf::from("-")]
134        } else {
135            files
136        }
137    }
138
139    /// Check if a string looks like a selector.
140    fn looks_like_selector(&self, s: &str) -> bool {
141        // `-` is the stdin sentinel, not a selector.
142        if s == "-" {
143            return false;
144        }
145        // Empty string is not a selector
146        if s.is_empty() {
147            return false;
148        }
149
150        // Check if it's a valid selector pattern
151        // Contains only: digits, commas, colons, hyphens
152        // And at least one digit
153        let has_digit = s.chars().any(|c| c.is_ascii_digit());
154        if !has_digit {
155            return false;
156        }
157
158        // Check for invalid characters
159        let valid_chars = s
160            .chars()
161            .all(|c| c.is_ascii_digit() || c == ',' || c == ':' || c == '-');
162
163        if !valid_chars {
164            return false;
165        }
166
167        // Additional validation: colons must be between numbers
168        // e.g., "23:260" is valid, but ":260" or "23:" is not
169        if s.contains(':') {
170            for part in s.split(',') {
171                if let Some((line, col)) = part.split_once(':') {
172                    // Both sides must be non-empty numbers
173                    if line.is_empty() || col.is_empty() {
174                        return false;
175                    }
176                    if !line.chars().all(|c| c.is_ascii_digit()) {
177                        return false;
178                    }
179                    if !col.chars().all(|c| c.is_ascii_digit()) {
180                        return false;
181                    }
182                }
183            }
184        }
185
186        true
187    }
188
189    /// Validate CLI arguments and check for conflicts.
190    pub fn validate(&self) -> crate::Result<()> {
191        if self.invert && self.regex.is_none() {
192            return Err(crate::SelError::InvertWithoutRegex);
193        }
194        if self.char_context.is_some()
195            && self.regex.is_none()
196            && !self
197                .get_selector()
198                .as_ref()
199                .is_some_and(|s| s.contains(':'))
200        {
201            return Err(crate::SelError::CharContextWithoutTarget);
202        }
203
204        Ok(())
205    }
206
207    /// Get the color mode based on the --color flag and terminal detection.
208    pub fn color_mode(&self) -> ColorMode {
209        match self.color.as_deref() {
210            Some("always") => ColorMode::Always,
211            Some("never") => ColorMode::Never,
212            Some("auto") | None => {
213                // Check if stdout is a terminal
214                if std::io::stdout().is_terminal() {
215                    ColorMode::Always
216                } else {
217                    ColorMode::Never
218                }
219            }
220            Some(_) => ColorMode::Never, // Invalid value, default to never
221        }
222    }
223}
224
225use crate::app::{NonSeek, Seek, Stage1};
226use crate::context::{LineContext, NoContext};
227use crate::format::{FormatOpts, FragmentFormatter, PlainFormatter, digits};
228use crate::matcher::{AllMatcher, LineMatcher, PositionMatcher, RegexMatcher};
229use crate::sink::{FileSink, Sink, StdoutSink};
230use crate::source::{FileSource, Source, StdinSource};
231use crate::{App, LineSpec, Selector};
232
233impl Cli {
234    /// Construct the output sink based on `--output`/`--force` flags.
235    pub fn make_sink(&self) -> crate::Result<Box<dyn Sink>> {
236        match self.output.as_deref() {
237            None | Some("-") => Ok(Box::new(StdoutSink::new())),
238            Some(path) => {
239                let sink = FileSink::create(std::path::Path::new(path), self.force)?;
240                Ok(Box::new(sink))
241            }
242        }
243    }
244
245    /// Resolve `--color` against whether the sink is a terminal.
246    fn resolve_color(&self, to_terminal: bool) -> bool {
247        match self.color.as_deref() {
248            Some("always") => true,
249            Some("never") => false,
250            _ => to_terminal,
251        }
252    }
253
254    fn line_number_width(&self) -> usize {
255        let Some(raw) = self.get_selector() else {
256            return 4;
257        };
258        let Ok(selector) = Selector::parse(&raw).map(|sel| sel.normalize()) else {
259            return 4;
260        };
261        let max_line = match selector {
262            Selector::All => None,
263            Selector::LineNumbers(specs) => specs
264                .into_iter()
265                .map(|spec| match spec {
266                    LineSpec::Single(n) | LineSpec::Range(_, n) => n,
267                })
268                .max(),
269            Selector::Positions(positions) => positions.into_iter().map(|pos| pos.line).max(),
270        };
271        max_line.map_or(4, |line| 4.max(digits(line as u64)))
272    }
273
274    fn format_opts(
275        &self,
276        show_filename: bool,
277        filename: Option<String>,
278        color: bool,
279    ) -> FormatOpts {
280        FormatOpts {
281            show_line_numbers: !self.no_line_numbers,
282            show_filename,
283            filename,
284            color,
285            // Target marker (`> `) only appears in context-aware output.
286            target_marker: matches!(self.context, Some(n) if n > 0),
287            line_number_width: self.line_number_width(),
288        }
289    }
290
291    /// Build a ready-to-run `App` for a single file.
292    ///
293    /// Callers iterate over `get_files()` and build one `App` per file.
294    pub fn into_app_for_file(
295        &self,
296        path: &std::path::Path,
297        show_filename: bool,
298    ) -> crate::Result<App<Seek>> {
299        let sink = self.make_sink()?;
300        self.into_app_for_file_with_sink(path, show_filename, sink)
301    }
302
303    pub fn into_app_for_file_with_sink(
304        &self,
305        path: &std::path::Path,
306        show_filename: bool,
307        sink: Box<dyn Sink>,
308    ) -> crate::Result<App<Seek>> {
309        let source = FileSource::open(path)?;
310        let filename = if show_filename {
311            Some(source.label().to_string())
312        } else {
313            None
314        };
315        let color = self.resolve_color(sink.is_terminal());
316        let opts = self.format_opts(show_filename, filename, color);
317
318        // Matcher + seek stage.
319        let stage2 = Stage1::with_seekable_source(Box::new(source));
320        let stage3 = if let Some(pat) = &self.regex {
321            stage2.with_matcher(Box::new(RegexMatcher::new(pat, self.invert)?))
322        } else if let Some(raw) = self.get_selector() {
323            let sel = Selector::parse(&raw)?;
324            match sel {
325                Selector::All => stage2.with_matcher(Box::new(AllMatcher)),
326                Selector::LineNumbers(_) => {
327                    stage2.with_matcher(Box::new(LineMatcher::from_selector(&sel)))
328                }
329                Selector::Positions(_) => {
330                    stage2.with_position_matcher(PositionMatcher::from_selector(&sel))
331                }
332            }
333        } else {
334            stage2.with_matcher(Box::new(AllMatcher))
335        };
336
337        // Expander.
338        let stage4 = match self.context {
339            Some(n) if n > 0 => stage3.with_expander(Box::new(LineContext::new(n))),
340            _ => stage3.with_expander(Box::new(NoContext)),
341        };
342
343        // Formatter.
344        let stage5 = if let Some(n) = self.char_context {
345            stage4.with_formatter(Box::new(FragmentFormatter::new(opts, n)))
346        } else {
347            stage4.with_formatter(Box::new(PlainFormatter::new(opts)))
348        };
349
350        Ok(stage5.with_sink(sink))
351    }
352
353    /// Build a ready-to-run `App` for stdin input.
354    ///
355    /// Returns `PositionalWithStdin` when paired with a positional selector
356    /// (line:column), which requires a seekable source.
357    pub fn into_app_for_stdin(&self, show_filename: bool) -> crate::Result<App<NonSeek>> {
358        let sink = self.make_sink()?;
359        self.into_app_for_stdin_with_sink(show_filename, sink)
360    }
361
362    pub fn into_app_for_stdin_with_sink(
363        &self,
364        show_filename: bool,
365        sink: Box<dyn Sink>,
366    ) -> crate::Result<App<NonSeek>> {
367        if let Some(raw) = self.get_selector()
368            && raw.contains(':')
369        {
370            return Err(crate::SelError::PositionalWithStdin);
371        }
372        let source = StdinSource::new();
373        let filename = if show_filename {
374            Some("-".to_string())
375        } else {
376            None
377        };
378        let color = self.resolve_color(sink.is_terminal());
379        let opts = self.format_opts(show_filename, filename, color);
380
381        let stage2 = Stage1::with_nonseekable_source(Box::new(source));
382        let stage3 = if let Some(pat) = &self.regex {
383            stage2.with_matcher(Box::new(RegexMatcher::new(pat, self.invert)?))
384        } else if let Some(raw) = self.get_selector() {
385            let sel = Selector::parse(&raw)?;
386            match sel {
387                Selector::All => stage2.with_matcher(Box::new(AllMatcher)),
388                Selector::LineNumbers(_) => {
389                    stage2.with_matcher(Box::new(LineMatcher::from_selector(&sel)))
390                }
391                Selector::Positions(_) => return Err(crate::SelError::PositionalWithStdin),
392            }
393        } else {
394            stage2.with_matcher(Box::new(AllMatcher))
395        };
396
397        let stage4 = match self.context {
398            Some(n) if n > 0 => stage3.with_expander(Box::new(LineContext::new(n))),
399            _ => stage3.with_expander(Box::new(NoContext)),
400        };
401
402        let stage5 = if let Some(n) = self.char_context {
403            stage4.with_formatter(Box::new(FragmentFormatter::new(opts, n)))
404        } else {
405            stage4.with_formatter(Box::new(PlainFormatter::new(opts)))
406        };
407
408        Ok(stage5.with_sink(sink))
409    }
410}
411
412/// Color output mode.
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub enum ColorMode {
415    /// Always colorize output.
416    Always,
417    /// Never colorize output.
418    Never,
419}
420
421impl ColorMode {
422    /// Returns true if coloring should be applied.
423    pub fn should_colorize(&self) -> bool {
424        matches!(self, Self::Always)
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_cli_with_selector() {
434        let cli = Cli::parse_from(["sel", "10-20", "file.txt"]);
435        assert_eq!(cli.get_selector(), Some("10-20".to_string()));
436        assert_eq!(cli.get_files().len(), 1);
437        assert_eq!(cli.get_files()[0], PathBuf::from("file.txt"));
438    }
439
440    #[test]
441    fn test_cli_without_selector() {
442        let cli = Cli::parse_from(["sel", "file.txt"]);
443        assert_eq!(cli.get_selector(), None);
444        assert_eq!(cli.get_files().len(), 1);
445        assert_eq!(cli.get_files()[0], PathBuf::from("file.txt"));
446    }
447
448    #[test]
449    fn test_cli_with_context() {
450        let cli = Cli::parse_from(["sel", "-c", "3", "42", "file.txt"]);
451        assert_eq!(cli.context, Some(3));
452        assert_eq!(cli.get_selector(), Some("42".to_string()));
453        assert_eq!(cli.get_files().len(), 1);
454    }
455
456    #[test]
457    fn test_cli_regex_mode() {
458        let cli = Cli::parse_from(["sel", "-e", "ERROR", "log.txt"]);
459        assert_eq!(cli.regex, Some("ERROR".to_string()));
460        assert_eq!(cli.get_selector(), None);
461        assert_eq!(cli.get_files().len(), 1);
462        assert_eq!(cli.get_files()[0], PathBuf::from("log.txt"));
463    }
464
465    #[test]
466    fn test_cli_regex_multiple_files() {
467        let cli = Cli::parse_from(["sel", "-e", "ERROR", "log1.txt", "log2.txt"]);
468        assert_eq!(cli.regex, Some("ERROR".to_string()));
469        assert_eq!(cli.get_files().len(), 2);
470    }
471
472    #[test]
473    fn test_looks_like_selector() {
474        let cli = Cli::parse_from(["sel", "file.txt"]);
475        assert!(cli.looks_like_selector("42"));
476        assert!(cli.looks_like_selector("10-20"));
477        assert!(cli.looks_like_selector("1,5,10-15"));
478        assert!(cli.looks_like_selector("23:260"));
479        assert!(!cli.looks_like_selector("file.txt"));
480        assert!(!cli.looks_like_selector(""));
481        assert!(!cli.looks_like_selector(":260"));
482        assert!(!cli.looks_like_selector("23:"));
483    }
484}