use clap::Parser;
use std::io::IsTerminal;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "sel")]
#[command(author = "InkyQuill")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "Select slices from text files", long_about = None)]
#[command(
long_about = "Extract fragments from text files by line numbers, ranges, positions (line:column), or regex patterns.
EXAMPLES:
sel 30-35 file.txt Output lines 30-35
sel 10,15-20,22 file.txt Output lines 10, 15-20, and 22
sel -c 3 42 file.txt Show line 42 with 3 lines of context
sel -n 10 23:260 file.txt Show position line 23, column 260 with char context
sel -e ERROR log.txt Search for 'ERROR' pattern
sel file.txt Output entire file with line numbers (like cat -n)"
)]
pub struct Cli {
#[arg(short = 'c', long = "context", value_name = "N")]
pub context: Option<usize>,
#[arg(short = 'n', long = "char-context", value_name = "N")]
pub char_context: Option<usize>,
#[arg(short = 'l', long = "no-line-numbers")]
pub no_line_numbers: bool,
#[arg(short = 'e', long = "regex", value_name = "PATTERN")]
pub regex: Option<String>,
#[arg(short = 'v', long = "invert-match")]
pub invert: bool,
#[arg(short = 'H', long = "with-filename")]
pub with_filename: bool,
#[arg(long = "color", value_name = "WHEN")]
pub color: Option<String>,
#[arg(short = 'o', long = "output", value_name = "FILE")]
pub output: Option<String>,
#[arg(long = "force")]
pub force: bool,
#[arg(value_name = "SELECTOR_OR_FILE")]
pub args: Vec<String>,
}
impl Cli {
pub fn get_selector(&self) -> Option<String> {
if self.regex.is_some() {
return None;
}
if self.args.is_empty() {
return None;
}
let first = &self.args[0];
if self.looks_like_selector(first) {
Some(first.clone())
} else {
None
}
}
pub fn get_files(&self) -> Vec<PathBuf> {
if self.args.is_empty() {
return vec![PathBuf::from("-")];
}
if self.regex.is_some() {
return self.args.iter().map(PathBuf::from).collect();
}
let start = if self.looks_like_selector(&self.args[0]) {
1
} else {
0
};
let files: Vec<_> = self.args[start..].iter().map(PathBuf::from).collect();
if files.is_empty() {
vec![PathBuf::from("-")]
} else {
files
}
}
fn looks_like_selector(&self, s: &str) -> bool {
if s == "-" {
return false;
}
if s.is_empty() {
return false;
}
let has_digit = s.chars().any(|c| c.is_ascii_digit());
if !has_digit {
return false;
}
let valid_chars = s
.chars()
.all(|c| c.is_ascii_digit() || c == ',' || c == ':' || c == '-');
if !valid_chars {
return false;
}
if s.contains(':') {
for part in s.split(',') {
if let Some((line, col)) = part.split_once(':') {
if line.is_empty() || col.is_empty() {
return false;
}
if !line.chars().all(|c| c.is_ascii_digit()) {
return false;
}
if !col.chars().all(|c| c.is_ascii_digit()) {
return false;
}
}
}
}
true
}
pub fn validate(&self) -> crate::Result<()> {
if self.invert && self.regex.is_none() {
return Err(crate::SelError::InvertWithoutRegex);
}
if self.char_context.is_some()
&& self.regex.is_none()
&& !self
.get_selector()
.as_ref()
.is_some_and(|s| s.contains(':'))
{
return Err(crate::SelError::CharContextWithoutTarget);
}
Ok(())
}
pub fn color_mode(&self) -> ColorMode {
match self.color.as_deref() {
Some("always") => ColorMode::Always,
Some("never") => ColorMode::Never,
Some("auto") | None => {
if std::io::stdout().is_terminal() {
ColorMode::Always
} else {
ColorMode::Never
}
}
Some(_) => ColorMode::Never, }
}
}
use crate::app::{NonSeek, Seek, Stage1};
use crate::context::{LineContext, NoContext};
use crate::format::{FormatOpts, FragmentFormatter, PlainFormatter};
use crate::matcher::{AllMatcher, LineMatcher, PositionMatcher, RegexMatcher};
use crate::sink::{FileSink, StdoutSink};
use crate::source::{FileSource, Source, StdinSource};
use crate::{App, Selector};
impl Cli {
fn make_sink(&self) -> crate::Result<Box<dyn crate::sink::Sink>> {
match self.output.as_deref() {
None | Some("-") => Ok(Box::new(StdoutSink::new())),
Some(path) => {
let sink = FileSink::create(std::path::Path::new(path), self.force)?;
Ok(Box::new(sink))
}
}
}
fn resolve_color(&self, to_terminal: bool) -> bool {
match self.color.as_deref() {
Some("always") => true,
Some("never") => false,
_ => to_terminal,
}
}
pub fn into_app_for_file(
&self,
path: &std::path::Path,
show_filename: bool,
) -> crate::Result<App<Seek>> {
let source = FileSource::open(path)?;
let filename = if show_filename {
Some(source.label().to_string())
} else {
None
};
let sink = self.make_sink()?;
let color = self.resolve_color(sink.is_terminal());
let opts = FormatOpts {
show_line_numbers: !self.no_line_numbers,
show_filename,
filename,
color,
target_marker: matches!(self.context, Some(n) if n > 0),
};
let stage2 = Stage1::with_seekable_source(Box::new(source));
let stage3 = if let Some(pat) = &self.regex {
stage2.with_matcher(Box::new(RegexMatcher::new(pat, self.invert)?))
} else if let Some(raw) = self.get_selector() {
let sel = Selector::parse(&raw)?;
match sel {
Selector::All => stage2.with_matcher(Box::new(AllMatcher)),
Selector::LineNumbers(_) => {
stage2.with_matcher(Box::new(LineMatcher::from_selector(&sel)))
}
Selector::Positions(_) => {
stage2.with_position_matcher(PositionMatcher::from_selector(&sel))
}
}
} else {
stage2.with_matcher(Box::new(AllMatcher))
};
let stage4 = match self.context {
Some(n) if n > 0 => stage3.with_expander(Box::new(LineContext::new(n))),
_ => stage3.with_expander(Box::new(NoContext)),
};
let stage5 = if let Some(n) = self.char_context {
stage4.with_formatter(Box::new(FragmentFormatter::new(opts, n)))
} else {
stage4.with_formatter(Box::new(PlainFormatter::new(opts)))
};
Ok(stage5.with_sink(sink))
}
pub fn into_app_for_stdin(&self, show_filename: bool) -> crate::Result<App<NonSeek>> {
if let Some(raw) = self.get_selector()
&& raw.contains(':')
{
return Err(crate::SelError::PositionalWithStdin);
}
let source = StdinSource::new();
let filename = if show_filename {
Some("-".to_string())
} else {
None
};
let sink = self.make_sink()?;
let color = self.resolve_color(sink.is_terminal());
let opts = FormatOpts {
show_line_numbers: !self.no_line_numbers,
show_filename,
filename,
color,
target_marker: matches!(self.context, Some(n) if n > 0),
};
let stage2 = Stage1::with_nonseekable_source(Box::new(source));
let stage3 = if let Some(pat) = &self.regex {
stage2.with_matcher(Box::new(RegexMatcher::new(pat, self.invert)?))
} else if let Some(raw) = self.get_selector() {
let sel = Selector::parse(&raw)?;
match sel {
Selector::All => stage2.with_matcher(Box::new(AllMatcher)),
Selector::LineNumbers(_) => {
stage2.with_matcher(Box::new(LineMatcher::from_selector(&sel)))
}
Selector::Positions(_) => return Err(crate::SelError::PositionalWithStdin),
}
} else {
stage2.with_matcher(Box::new(AllMatcher))
};
let stage4 = match self.context {
Some(n) if n > 0 => stage3.with_expander(Box::new(LineContext::new(n))),
_ => stage3.with_expander(Box::new(NoContext)),
};
let stage5 = if let Some(n) = self.char_context {
stage4.with_formatter(Box::new(FragmentFormatter::new(opts, n)))
} else {
stage4.with_formatter(Box::new(PlainFormatter::new(opts)))
};
Ok(stage5.with_sink(sink))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorMode {
Always,
Never,
}
impl ColorMode {
pub fn should_colorize(&self) -> bool {
matches!(self, Self::Always)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cli_with_selector() {
let cli = Cli::parse_from(["sel", "10-20", "file.txt"]);
assert_eq!(cli.get_selector(), Some("10-20".to_string()));
assert_eq!(cli.get_files().len(), 1);
assert_eq!(cli.get_files()[0], PathBuf::from("file.txt"));
}
#[test]
fn test_cli_without_selector() {
let cli = Cli::parse_from(["sel", "file.txt"]);
assert_eq!(cli.get_selector(), None);
assert_eq!(cli.get_files().len(), 1);
assert_eq!(cli.get_files()[0], PathBuf::from("file.txt"));
}
#[test]
fn test_cli_with_context() {
let cli = Cli::parse_from(["sel", "-c", "3", "42", "file.txt"]);
assert_eq!(cli.context, Some(3));
assert_eq!(cli.get_selector(), Some("42".to_string()));
assert_eq!(cli.get_files().len(), 1);
}
#[test]
fn test_cli_regex_mode() {
let cli = Cli::parse_from(["sel", "-e", "ERROR", "log.txt"]);
assert_eq!(cli.regex, Some("ERROR".to_string()));
assert_eq!(cli.get_selector(), None);
assert_eq!(cli.get_files().len(), 1);
assert_eq!(cli.get_files()[0], PathBuf::from("log.txt"));
}
#[test]
fn test_cli_regex_multiple_files() {
let cli = Cli::parse_from(["sel", "-e", "ERROR", "log1.txt", "log2.txt"]);
assert_eq!(cli.regex, Some("ERROR".to_string()));
assert_eq!(cli.get_files().len(), 2);
}
#[test]
fn test_looks_like_selector() {
let cli = Cli::parse_from(["sel", "file.txt"]);
assert!(cli.looks_like_selector("42"));
assert!(cli.looks_like_selector("10-20"));
assert!(cli.looks_like_selector("1,5,10-15"));
assert!(cli.looks_like_selector("23:260"));
assert!(!cli.looks_like_selector("file.txt"));
assert!(!cli.looks_like_selector(""));
assert!(!cli.looks_like_selector(":260"));
assert!(!cli.looks_like_selector("23:"));
}
}