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, 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 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 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: matches!(self.context, Some(n) if n > 0),
287 line_number_width: self.line_number_width(),
288 }
289 }
290
291 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub enum ColorMode {
415 Always,
417 Never,
419}
420
421impl ColorMode {
422 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}