1use clap::Parser;
4use std::io::IsTerminal;
5use std::path::PathBuf;
6
7#[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 #[arg(short = 'c', long = "context", value_name = "N")]
27 pub context: Option<usize>,
28
29 #[arg(short = 'n', long = "char-context", value_name = "N")]
33 pub char_context: Option<usize>,
34
35 #[arg(short = 'l', long = "no-line-numbers")]
39 pub no_line_numbers: bool,
40
41 #[arg(short = 'e', long = "regex", value_name = "PATTERN")]
46 pub regex: Option<String>,
47
48 #[arg(short = 'v', long = "invert-match")]
50 pub invert: bool,
51
52 #[arg(short = 'H', long = "with-filename")]
56 pub with_filename: bool,
57
58 #[arg(long = "color", value_name = "WHEN")]
62 pub color: Option<String>,
63
64 #[arg(short = 'o', long = "output", value_name = "FILE")]
66 pub output: Option<String>,
67
68 #[arg(long = "force")]
70 pub force: bool,
71
72 #[arg(value_name = "SELECTOR_OR_FILE")]
80 pub args: Vec<String>,
81}
82
83impl Cli {
84 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 let first = &self.args[0];
96
97 if self.looks_like_selector(first) {
104 Some(first.clone())
105 } else {
106 None
107 }
108 }
109
110 pub fn get_files(&self) -> Vec<PathBuf> {
115 if self.args.is_empty() {
116 return vec![PathBuf::from("-")];
117 }
118
119 if self.regex.is_some() {
121 return self.args.iter().map(PathBuf::from).collect();
122 }
123
124 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 fn looks_like_selector(&self, s: &str) -> bool {
141 if s == "-" {
143 return false;
144 }
145 if s.is_empty() {
147 return false;
148 }
149
150 let has_digit = s.chars().any(|c| c.is_ascii_digit());
154 if !has_digit {
155 return false;
156 }
157
158 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 if s.contains(':') {
170 for part in s.split(',') {
171 if let Some((line, col)) = part.split_once(':') {
172 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 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 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 if std::io::stdout().is_terminal() {
215 ColorMode::Always
216 } else {
217 ColorMode::Never
218 }
219 }
220 Some(_) => ColorMode::Never, }
222 }
223}
224
225use crate::app::{NonSeek, Seek, Stage1};
226use crate::context::{LineContext, NoContext};
227use crate::format::{FormatOpts, FragmentFormatter, PlainFormatter};
228use crate::matcher::{AllMatcher, LineMatcher, PositionMatcher, RegexMatcher};
229use crate::sink::{FileSink, StdoutSink};
230use crate::source::{FileSource, Source, StdinSource};
231use crate::{App, Selector};
232
233impl Cli {
234 fn make_sink(&self) -> crate::Result<Box<dyn crate::sink::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 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 pub fn into_app_for_file(
258 &self,
259 path: &std::path::Path,
260 show_filename: bool,
261 ) -> crate::Result<App<Seek>> {
262 let source = FileSource::open(path)?;
263 let filename = if show_filename {
264 Some(source.label().to_string())
265 } else {
266 None
267 };
268 let sink = self.make_sink()?;
269 let color = self.resolve_color(sink.is_terminal());
270 let opts = FormatOpts {
271 show_line_numbers: !self.no_line_numbers,
272 show_filename,
273 filename,
274 color,
275 target_marker: matches!(self.context, Some(n) if n > 0),
277 };
278
279 let stage2 = Stage1::with_seekable_source(Box::new(source));
281 let stage3 = if let Some(pat) = &self.regex {
282 stage2.with_matcher(Box::new(RegexMatcher::new(pat, self.invert)?))
283 } else if let Some(raw) = self.get_selector() {
284 let sel = Selector::parse(&raw)?;
285 match sel {
286 Selector::All => stage2.with_matcher(Box::new(AllMatcher)),
287 Selector::LineNumbers(_) => {
288 stage2.with_matcher(Box::new(LineMatcher::from_selector(&sel)))
289 }
290 Selector::Positions(_) => {
291 stage2.with_position_matcher(PositionMatcher::from_selector(&sel))
292 }
293 }
294 } else {
295 stage2.with_matcher(Box::new(AllMatcher))
296 };
297
298 let stage4 = match self.context {
300 Some(n) if n > 0 => stage3.with_expander(Box::new(LineContext::new(n))),
301 _ => stage3.with_expander(Box::new(NoContext)),
302 };
303
304 let stage5 = if let Some(n) = self.char_context {
306 stage4.with_formatter(Box::new(FragmentFormatter::new(opts, n)))
307 } else {
308 stage4.with_formatter(Box::new(PlainFormatter::new(opts)))
309 };
310
311 Ok(stage5.with_sink(sink))
312 }
313
314 pub fn into_app_for_stdin(&self, show_filename: bool) -> crate::Result<App<NonSeek>> {
319 if let Some(raw) = self.get_selector()
320 && raw.contains(':')
321 {
322 return Err(crate::SelError::PositionalWithStdin);
323 }
324 let source = StdinSource::new();
325 let filename = if show_filename {
326 Some("-".to_string())
327 } else {
328 None
329 };
330 let sink = self.make_sink()?;
331 let color = self.resolve_color(sink.is_terminal());
332 let opts = FormatOpts {
333 show_line_numbers: !self.no_line_numbers,
334 show_filename,
335 filename,
336 color,
337 target_marker: matches!(self.context, Some(n) if n > 0),
339 };
340
341 let stage2 = Stage1::with_nonseekable_source(Box::new(source));
342 let stage3 = if let Some(pat) = &self.regex {
343 stage2.with_matcher(Box::new(RegexMatcher::new(pat, self.invert)?))
344 } else if let Some(raw) = self.get_selector() {
345 let sel = Selector::parse(&raw)?;
346 match sel {
347 Selector::All => stage2.with_matcher(Box::new(AllMatcher)),
348 Selector::LineNumbers(_) => {
349 stage2.with_matcher(Box::new(LineMatcher::from_selector(&sel)))
350 }
351 Selector::Positions(_) => return Err(crate::SelError::PositionalWithStdin),
352 }
353 } else {
354 stage2.with_matcher(Box::new(AllMatcher))
355 };
356
357 let stage4 = match self.context {
358 Some(n) if n > 0 => stage3.with_expander(Box::new(LineContext::new(n))),
359 _ => stage3.with_expander(Box::new(NoContext)),
360 };
361
362 let stage5 = if let Some(n) = self.char_context {
363 stage4.with_formatter(Box::new(FragmentFormatter::new(opts, n)))
364 } else {
365 stage4.with_formatter(Box::new(PlainFormatter::new(opts)))
366 };
367
368 Ok(stage5.with_sink(sink))
369 }
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum ColorMode {
375 Always,
377 Never,
379}
380
381impl ColorMode {
382 pub fn should_colorize(&self) -> bool {
384 matches!(self, Self::Always)
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn test_cli_with_selector() {
394 let cli = Cli::parse_from(["sel", "10-20", "file.txt"]);
395 assert_eq!(cli.get_selector(), Some("10-20".to_string()));
396 assert_eq!(cli.get_files().len(), 1);
397 assert_eq!(cli.get_files()[0], PathBuf::from("file.txt"));
398 }
399
400 #[test]
401 fn test_cli_without_selector() {
402 let cli = Cli::parse_from(["sel", "file.txt"]);
403 assert_eq!(cli.get_selector(), None);
404 assert_eq!(cli.get_files().len(), 1);
405 assert_eq!(cli.get_files()[0], PathBuf::from("file.txt"));
406 }
407
408 #[test]
409 fn test_cli_with_context() {
410 let cli = Cli::parse_from(["sel", "-c", "3", "42", "file.txt"]);
411 assert_eq!(cli.context, Some(3));
412 assert_eq!(cli.get_selector(), Some("42".to_string()));
413 assert_eq!(cli.get_files().len(), 1);
414 }
415
416 #[test]
417 fn test_cli_regex_mode() {
418 let cli = Cli::parse_from(["sel", "-e", "ERROR", "log.txt"]);
419 assert_eq!(cli.regex, Some("ERROR".to_string()));
420 assert_eq!(cli.get_selector(), None);
421 assert_eq!(cli.get_files().len(), 1);
422 assert_eq!(cli.get_files()[0], PathBuf::from("log.txt"));
423 }
424
425 #[test]
426 fn test_cli_regex_multiple_files() {
427 let cli = Cli::parse_from(["sel", "-e", "ERROR", "log1.txt", "log2.txt"]);
428 assert_eq!(cli.regex, Some("ERROR".to_string()));
429 assert_eq!(cli.get_files().len(), 2);
430 }
431
432 #[test]
433 fn test_looks_like_selector() {
434 let cli = Cli::parse_from(["sel", "file.txt"]);
435 assert!(cli.looks_like_selector("42"));
436 assert!(cli.looks_like_selector("10-20"));
437 assert!(cli.looks_like_selector("1,5,10-15"));
438 assert!(cli.looks_like_selector("23:260"));
439 assert!(!cli.looks_like_selector("file.txt"));
440 assert!(!cli.looks_like_selector(""));
441 assert!(!cli.looks_like_selector(":260"));
442 assert!(!cli.looks_like_selector("23:"));
443 }
444}