Skip to content

Commit 3b61c38

Browse files
authored
refactor(builtins): migrate sort, uniq, tr, tar, rg to ArgParser (#898)
Replace manual `while i < args.len()` index management with ArgParser in 5 builtins: sort, uniq, tr (cuttr.rs), tar (archive.rs), and rg.\n\nAdd `bool_flags()` method to ArgParser for combined short boolean flags like `-rnuf`.\n\nCloses #880
1 parent 7b7e2a0 commit 3b61c38

6 files changed

Lines changed: 204 additions & 147 deletions

File tree

crates/bashkit/src/builtins/archive.rs

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -91,57 +91,54 @@ impl Builtin for Tar {
9191
let mut files: Vec<String> = Vec::new();
9292

9393
// Parse arguments
94-
let mut i = 0;
95-
while i < ctx.args.len() {
96-
let arg = &ctx.args[i];
97-
if arg.starts_with('-') && !arg.starts_with("--") {
98-
// Track whether 'f' or 'C' consumed the next arg
99-
let mut consumed_next = false;
100-
for c in arg[1..].chars() {
94+
let mut p = super::arg_parser::ArgParser::new(ctx.args);
95+
while !p.is_done() {
96+
if let Some(val) = p.flag_value_opt("-f") {
97+
archive_file = Some(val.to_string());
98+
} else if let Some(val) = p.flag_value_opt("-C") {
99+
change_dir = Some(val.to_string());
100+
} else if p.is_flag() {
101+
// Combined short flags like -czf where f/C take the next arg
102+
let arg = p.current().unwrap();
103+
let chars: Vec<char> = arg[1..].chars().collect();
104+
p.advance();
105+
for c in &chars {
101106
match c {
102107
'c' => create = true,
103108
'x' => extract = true,
104109
't' => list = true,
105110
'v' => verbose = true,
106111
'z' => gzip = true,
107112
'O' => to_stdout = true,
108-
'f' => {
109-
i += 1;
110-
consumed_next = true;
111-
if i >= ctx.args.len() {
113+
'f' => match p.positional() {
114+
Some(val) => archive_file = Some(val.to_string()),
115+
None => {
112116
return Ok(ExecResult::err(
113117
"tar: option requires an argument -- 'f'\n".to_string(),
114118
2,
115119
));
116120
}
117-
archive_file = Some(ctx.args[i].clone());
118-
}
119-
'C' => {
120-
i += 1;
121-
consumed_next = true;
122-
if i >= ctx.args.len() {
121+
},
122+
'C' => match p.positional() {
123+
Some(val) => change_dir = Some(val.to_string()),
124+
None => {
123125
return Ok(ExecResult::err(
124126
"tar: option requires an argument -- 'C'\n".to_string(),
125127
2,
126128
));
127129
}
128-
change_dir = Some(ctx.args[i].clone());
129-
}
130+
},
130131
_ => {
131132
return Ok(ExecResult::err(
132133
format!("tar: invalid option -- '{}'\n", c),
133134
2,
134135
));
135136
}
136137
}
137-
if consumed_next {
138-
break;
139-
}
140138
}
141-
} else {
142-
files.push(arg.clone());
139+
} else if let Some(arg) = p.positional() {
140+
files.push(arg.to_string());
143141
}
144-
i += 1;
145142
}
146143

147144
// Check for exactly one of -c, -x, -t

crates/bashkit/src/builtins/arg_parser.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,26 @@ impl<'a> ArgParser<'a> {
192192
.map(|s| s.starts_with('-') && s.len() > 1)
193193
.unwrap_or(false)
194194
}
195+
196+
/// Try to consume combined boolean short flags (e.g., `-rnuf`).
197+
///
198+
/// If the current arg starts with `-` (not `--`), has length > 1, and
199+
/// every character after `-` is in `allowed`, advances and returns the
200+
/// matched chars. Otherwise returns an empty vec without advancing.
201+
pub fn bool_flags(&mut self, allowed: &str) -> Vec<char> {
202+
if let Some(arg) = self.current()
203+
&& arg.starts_with('-')
204+
&& !arg.starts_with("--")
205+
&& arg.len() > 1
206+
{
207+
let chars: Vec<char> = arg[1..].chars().collect();
208+
if chars.iter().all(|c| allowed.contains(*c)) {
209+
self.advance();
210+
return chars;
211+
}
212+
}
213+
Vec::new()
214+
}
195215
}
196216

197217
#[cfg(test)]
@@ -331,6 +351,50 @@ mod tests {
331351
assert_eq!(p.rest().len(), 2);
332352
}
333353

354+
#[test]
355+
fn test_bool_flags() {
356+
let a = args(&["-rnuf", "file"]);
357+
let mut p = ArgParser::new(&a);
358+
let flags = p.bool_flags("rnufsz");
359+
assert_eq!(flags, vec!['r', 'n', 'u', 'f']);
360+
assert_eq!(p.current(), Some("file"));
361+
}
362+
363+
#[test]
364+
fn test_bool_flags_no_match_unknown_char() {
365+
let a = args(&["-rxn", "file"]);
366+
let mut p = ArgParser::new(&a);
367+
let flags = p.bool_flags("rn"); // 'x' not allowed
368+
assert!(flags.is_empty());
369+
assert_eq!(p.current(), Some("-rxn")); // not advanced
370+
}
371+
372+
#[test]
373+
fn test_bool_flags_long_flag_ignored() {
374+
let a = args(&["--verbose"]);
375+
let mut p = ArgParser::new(&a);
376+
let flags = p.bool_flags("verbose");
377+
assert!(flags.is_empty());
378+
}
379+
380+
#[test]
381+
fn test_bool_flags_single_dash_ignored() {
382+
let a = args(&["-"]);
383+
let mut p = ArgParser::new(&a);
384+
let flags = p.bool_flags("abc");
385+
assert!(flags.is_empty());
386+
assert_eq!(p.current(), Some("-")); // not advanced
387+
}
388+
389+
#[test]
390+
fn test_bool_flags_single_char() {
391+
let a = args(&["-v"]);
392+
let mut p = ArgParser::new(&a);
393+
let flags = p.bool_flags("v");
394+
assert_eq!(flags, vec!['v']);
395+
assert!(p.is_done());
396+
}
397+
334398
#[test]
335399
fn test_is_flag() {
336400
let a = args(&["-v", "-", "file", "--long"]);

crates/bashkit/src/builtins/cuttr.rs

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -281,30 +281,29 @@ impl Builtin for Tr {
281281
let mut complement = false;
282282

283283
// Parse flags (can be combined like -ds, -cd)
284-
let mut non_flag_args: Vec<&String> = Vec::new();
285-
for arg in ctx.args.iter() {
286-
if arg.starts_with('-')
287-
&& arg.len() > 1
288-
&& arg.chars().skip(1).all(|ch| "dscC".contains(ch))
289-
{
290-
for ch in arg.chars().skip(1) {
284+
let mut non_flag_args: Vec<String> = Vec::new();
285+
let mut p = super::arg_parser::ArgParser::new(ctx.args);
286+
while !p.is_done() {
287+
let flags = p.bool_flags("dscC");
288+
if !flags.is_empty() {
289+
for ch in flags {
291290
match ch {
292291
'd' => delete = true,
293292
's' => squeeze = true,
294293
'c' | 'C' => complement = true,
295294
_ => {}
296295
}
297296
}
298-
} else {
299-
non_flag_args.push(arg);
297+
} else if let Some(arg) = p.positional() {
298+
non_flag_args.push(arg.to_string());
300299
}
301300
}
302301

303302
if non_flag_args.is_empty() {
304303
return Ok(ExecResult::err("tr: missing operand\n".to_string(), 1));
305304
}
306305

307-
let mut set1 = expand_char_set(non_flag_args[0]);
306+
let mut set1 = expand_char_set(&non_flag_args[0]);
308307
if complement {
309308
// Complement: use all byte-range chars (0-255) NOT in set1.
310309
// Covers full Latin-1 range so binary data from /dev/urandom
@@ -321,7 +320,7 @@ impl Builtin for Tr {
321320
let result = if delete && squeeze {
322321
// -ds: delete SET1 chars, then squeeze SET2 chars
323322
let set2 = if non_flag_args.len() >= 2 {
324-
expand_char_set(non_flag_args[1])
323+
expand_char_set(&non_flag_args[1])
325324
} else {
326325
set1.clone()
327326
};
@@ -343,7 +342,7 @@ impl Builtin for Tr {
343342
));
344343
}
345344

346-
let set2 = expand_char_set(non_flag_args[1]);
345+
let set2 = expand_char_set(&non_flag_args[1]);
347346

348347
let translated: String = stdin
349348
.chars()

crates/bashkit/src/builtins/rg.rs

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
use async_trait::async_trait;
1919
use regex::Regex;
2020

21-
use super::search_common::{build_search_regex, collect_files_recursive, parse_numeric_flag_arg};
21+
use super::search_common::{build_search_regex, collect_files_recursive};
2222
use super::{Builtin, Context, read_text_file, resolve_path};
2323
use crate::error::{Error, Result};
2424
use crate::interpreter::ExecResult;
@@ -57,15 +57,38 @@ impl RgOptions {
5757
};
5858

5959
let mut positional = Vec::new();
60-
let mut i = 0;
61-
62-
while i < args.len() {
63-
let arg = &args[i];
64-
if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with("--") {
60+
let mut p = super::arg_parser::ArgParser::new(args);
61+
62+
while !p.is_done() {
63+
if let Ok(Some(val)) = p.flag_value("-m", "rg") {
64+
opts.max_count = Some(
65+
val.parse()
66+
.map_err(|_| Error::Execution(format!("rg: invalid -m value: {val}")))?,
67+
);
68+
} else if p.flag("--no-filename") {
69+
opts.no_filename = true;
70+
} else if p.flag("--no-line-number") {
71+
opts.line_numbers = false;
72+
} else if p.flag("--line-number") {
73+
opts.line_numbers = true;
74+
} else if p.flag("--color") {
75+
// no-op (may have separate value arg like "never", skip it)
76+
} else if p.current().is_some_and(|s| s.starts_with("--color=")) {
77+
// --color=VALUE is a no-op
78+
p.advance();
79+
} else if p.is_flag() {
80+
// Combined short flags like -inFw
81+
// Safe: is_flag() guarantees current() is Some
82+
let arg = p.current().expect("is_flag guarantees Some");
83+
if arg.starts_with("--") {
84+
// Unknown long option, skip
85+
p.advance();
86+
continue;
87+
}
6588
let chars: Vec<char> = arg[1..].chars().collect();
66-
let mut j = 0;
67-
while j < chars.len() {
68-
match chars[j] {
89+
p.advance();
90+
for (j, &c) in chars.iter().enumerate() {
91+
match c {
6992
'i' => opts.ignore_case = true,
7093
'n' => opts.line_numbers = true,
7194
'N' => opts.line_numbers = false,
@@ -75,29 +98,31 @@ impl RgOptions {
7598
'w' => opts.word_boundary = true,
7699
'F' => opts.fixed_strings = true,
77100
'm' => {
78-
opts.max_count =
79-
Some(parse_numeric_flag_arg(&chars, j, &mut i, args, "rg", "-m")?);
101+
// Rest of this flag group or next arg is the value
102+
let rest: String = chars[j + 1..].iter().collect();
103+
let num_str = if !rest.is_empty() {
104+
rest
105+
} else {
106+
match p.positional() {
107+
Some(v) => v.to_string(),
108+
None => {
109+
return Err(Error::Execution(
110+
"rg: -m requires an argument".to_string(),
111+
));
112+
}
113+
}
114+
};
115+
opts.max_count = Some(num_str.parse().map_err(|_| {
116+
Error::Execution(format!("rg: invalid -m value: {num_str}"))
117+
})?);
80118
break;
81119
}
82120
_ => {} // ignore unknown
83121
}
84-
j += 1;
85122
}
86-
} else if let Some(opt) = arg.strip_prefix("--") {
87-
if opt == "no-filename" {
88-
opts.no_filename = true;
89-
} else if opt == "color" || opt.starts_with("color=") {
90-
// no-op
91-
} else if opt == "no-line-number" {
92-
opts.line_numbers = false;
93-
} else if opt == "line-number" {
94-
opts.line_numbers = true;
95-
}
96-
// ignore other long options
97-
} else {
98-
positional.push(arg.clone());
123+
} else if let Some(arg) = p.positional() {
124+
positional.push(arg.to_string());
99125
}
100-
i += 1;
101126
}
102127

103128
if positional.is_empty() {

0 commit comments

Comments
 (0)