From f5513c4be5f7df9acdb3d96bb3f6936e56c9ccd7 Mon Sep 17 00:00:00 2001 From: pagedmov Date: Sun, 1 Mar 2026 21:16:33 -0500 Subject: [PATCH] implemented 'getopts' builtin --- src/builtin/getopts.rs | 231 ++++++++++++++++++++++++++++++++++++++++ src/builtin/mod.rs | 6 +- src/expand.rs | 70 +++++++++--- src/libsh/error.rs | 10 ++ src/parse/execute.rs | 4 +- src/parse/lex.rs | 6 +- src/readline/linebuf.rs | 2 +- src/readline/mod.rs | 10 +- src/state.rs | 30 ++++++ 9 files changed, 344 insertions(+), 25 deletions(-) create mode 100644 src/builtin/getopts.rs diff --git a/src/builtin/getopts.rs b/src/builtin/getopts.rs new file mode 100644 index 0000000..0eba2d0 --- /dev/null +++ b/src/builtin/getopts.rs @@ -0,0 +1,231 @@ +use std::str::FromStr; + +use ariadne::Fmt; + +use crate::{ + getopt::{Opt, OptSpec}, libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color}, parse::{NdRule, Node, execute::prepare_argv, lex::Span}, state::{self, VarFlags, VarKind, read_meta, read_vars, write_meta, write_vars} +}; + +enum OptMatch { + NoMatch, + IsMatch, + WantsArg +} + +struct GetOptsSpec { + silent_err: bool, + opt_specs: Vec +} + +impl GetOptsSpec { + pub fn matches(&self, ch: char) -> OptMatch { + for spec in &self.opt_specs { + let OptSpec { opt, takes_arg } = spec; + match opt { + Opt::Short(opt_ch) if ch == *opt_ch => { + if *takes_arg { + return OptMatch::WantsArg + } else { + return OptMatch::IsMatch + } + } + _ => { continue } + } + } + OptMatch::NoMatch + } +} + +impl FromStr for GetOptsSpec { + type Err = ShErr; + fn from_str(s: &str) -> Result { + let mut s = s; + let mut opt_specs = vec![]; + let mut silent_err = false; + if s.starts_with(':') { + silent_err = true; + s = &s[1..]; + } + + let mut chars = s.chars().peekable(); + while let Some(ch) = chars.peek() { + match ch { + ch if ch.is_alphanumeric() => { + let opt = Opt::Short(*ch); + chars.next(); + let takes_arg = chars.peek() == Some(&':'); + if takes_arg { + chars.next(); + } + opt_specs.push(OptSpec { opt, takes_arg }) + } + _ => return Err(ShErr::simple( + ShErrKind::ParseErr, + format!("unexpected character '{}'", ch.fg(next_color())) + )), + } + } + + Ok(GetOptsSpec { silent_err, opt_specs }) + } +} + +fn advance_optind(opt_index: usize, amount: usize) -> ShResult<()> { + write_vars(|v| v.set_var("OPTIND", VarKind::Str((opt_index + amount).to_string()), VarFlags::NONE)) +} + +fn getopts_inner(opts_spec: &GetOptsSpec, opt_var: &str, argv: &[String], blame: Span) -> ShResult<()> { + let opt_index = read_vars(|v| v.get_var("OPTIND").parse::().unwrap_or(1)); + // OPTIND is 1-based + let arr_idx = opt_index.saturating_sub(1); + + let Some(arg) = argv.get(arr_idx) else { + state::set_status(1); + return Ok(()) + }; + + // "--" stops option processing + if arg.as_str() == "--" { + advance_optind(opt_index, 1)?; + write_meta(|m| m.reset_getopts_char_offset()); + state::set_status(1); + return Ok(()) + } + + // Not an option — done + let Some(opt_str) = arg.strip_prefix('-') else { + state::set_status(1); + return Ok(()); + }; + + // Bare "-" is not an option + if opt_str.is_empty() { + state::set_status(1); + return Ok(()); + } + + let char_idx = read_meta(|m| m.getopts_char_offset()); + let Some(ch) = opt_str.chars().nth(char_idx) else { + // Ran out of chars in this arg (shouldn't normally happen), + // advance to next arg and signal done for this call + write_meta(|m| m.reset_getopts_char_offset()); + advance_optind(opt_index, 1)?; + state::set_status(1); + return Ok(()); + }; + + let last_char_in_arg = char_idx >= opt_str.len() - 1; + + // Advance past this character: either move to next char in this + // arg, or reset offset and bump OPTIND to the next arg. + let advance_one_char = |last: bool| -> ShResult<()> { + if last { + write_meta(|m| m.reset_getopts_char_offset()); + advance_optind(opt_index, 1)?; + } else { + write_meta(|m| m.inc_getopts_char_offset()); + } + Ok(()) + }; + + match opts_spec.matches(ch) { + OptMatch::NoMatch => { + advance_one_char(last_char_in_arg)?; + if opts_spec.silent_err { + write_vars(|v| v.set_var(opt_var, VarKind::Str("?".into()), VarFlags::NONE))?; + write_vars(|v| v.set_var("OPTARG", VarKind::Str(ch.to_string()), VarFlags::NONE))?; + } else { + write_vars(|v| v.set_var(opt_var, VarKind::Str("?".into()), VarFlags::NONE))?; + ShErr::at( + ShErrKind::ExecFail, + blame.clone(), + format!("illegal option '-{}'", ch.fg(next_color())) + ).print_error(); + } + state::set_status(0); + } + OptMatch::IsMatch => { + advance_one_char(last_char_in_arg)?; + write_vars(|v| v.set_var(opt_var, VarKind::Str(ch.to_string()), VarFlags::NONE))?; + state::set_status(0); + } + OptMatch::WantsArg => { + write_meta(|m| m.reset_getopts_char_offset()); + + if !last_char_in_arg { + // Remaining chars in this arg are the argument: -bVALUE + let optarg: String = opt_str.chars().skip(char_idx + 1).collect(); + write_vars(|v| v.set_var("OPTARG", VarKind::Str(optarg), VarFlags::NONE))?; + advance_optind(opt_index, 1)?; + } else if let Some(next_arg) = argv.get(arr_idx + 1) { + // Next arg is the argument + write_vars(|v| v.set_var("OPTARG", VarKind::Str(next_arg.clone()), VarFlags::NONE))?; + // Skip both the option arg and its value + advance_optind(opt_index, 2)?; + } else { + // Missing required argument + if opts_spec.silent_err { + write_vars(|v| v.set_var(opt_var, VarKind::Str(":".into()), VarFlags::NONE))?; + write_vars(|v| v.set_var("OPTARG", VarKind::Str(ch.to_string()), VarFlags::NONE))?; + } else { + write_vars(|v| v.set_var(opt_var, VarKind::Str("?".into()), VarFlags::NONE))?; + ShErr::at( + ShErrKind::ExecFail, + blame.clone(), + format!("option '-{}' requires an argument", ch.fg(next_color())) + ).print_error(); + } + advance_optind(opt_index, 1)?; + state::set_status(0); + return Ok(()); + } + + write_vars(|v| v.set_var(opt_var, VarKind::Str(ch.to_string()), VarFlags::NONE))?; + state::set_status(0); + } + } + + Ok(()) +} + +pub fn getopts(node: Node) -> ShResult<()> { + let span = node.get_span().clone(); + let NdRule::Command { + assignments: _, + argv, + } = node.class + else { + unreachable!() + }; + + let mut argv = prepare_argv(argv)?; + if !argv.is_empty() { argv.remove(0); } + let mut args = argv.into_iter(); + + let Some(arg_string) = args.next() else { + return Err(ShErr::at( + ShErrKind::ExecFail, + span, + "getopts: missing option spec" + )) + }; + let Some(opt_var) = args.next() else { + return Err(ShErr::at( + ShErrKind::ExecFail, + span, + "getopts: missing variable name" + )) + }; + + let opts_spec = GetOptsSpec::from_str(&arg_string.0) + .promote_err(arg_string.1.clone())?; + + let explicit_args: Vec = args.map(|s| s.0).collect(); + + if !explicit_args.is_empty() { + getopts_inner(&opts_spec, &opt_var.0, &explicit_args, span) + } else { + let pos_params: Vec = read_vars(|v| v.sh_argv().iter().skip(1).cloned().collect()); + getopts_inner(&opts_spec, &opt_var.0, &pos_params, span) + } +} diff --git a/src/builtin/mod.rs b/src/builtin/mod.rs index 6c7bf25..2b93aa4 100644 --- a/src/builtin/mod.rs +++ b/src/builtin/mod.rs @@ -24,12 +24,14 @@ pub mod zoltraak; pub mod map; pub mod arrops; pub mod intro; +pub mod getopts; -pub const BUILTINS: [&str; 43] = [ +pub const BUILTINS: [&str; 44] = [ "echo", "cd", "read", "export", "local", "pwd", "source", "shift", "jobs", "fg", "bg", "disown", "alias", "unalias", "return", "break", "continue", "exit", "zoltraak", "shopt", "builtin", "command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly", - "unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type" + "unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type", + "getopts" ]; pub fn true_builtin() -> ShResult<()> { diff --git a/src/expand.rs b/src/expand.rs index 36562d7..4570f63 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -2,10 +2,11 @@ use std::collections::HashSet; use std::iter::Peekable; use std::str::{Chars, FromStr}; +use ariadne::Fmt; use glob::Pattern; use regex::Regex; -use crate::libsh::error::{ShErr, ShErrKind, ShResult}; +use crate::libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color}; use crate::parse::execute::exec_input; use crate::parse::lex::{LexFlags, LexStream, QuoteState, Tk, TkFlags, TkRule, is_hard_sep}; use crate::parse::{Redir, RedirType}; @@ -24,7 +25,9 @@ impl Tk { pub fn expand(self) -> ShResult { let flags = self.flags; let span = self.span.clone(); - let exp = Expander::new(self)?.expand()?; + let exp = Expander::new(self)? + .expand() + .promote_err(span.clone())?; let class = TkRule::Expanded { exp }; Ok(Self { class, span, flags }) } @@ -646,10 +649,11 @@ enum ArithTk { Op(ArithOp), LParen, RParen, + Var(String) } impl ArithTk { - pub fn tokenize(raw: &str) -> ShResult> { + pub fn tokenize(raw: &str) -> ShResult>> { let mut tokens = Vec::new(); let mut chars = raw.chars().peekable(); @@ -687,16 +691,28 @@ impl ArithTk { tokens.push(Self::RParen); chars.next(); } + _ if ch.is_alphabetic() || ch == '_' => { + chars.next(); + let mut var_name = ch.to_string(); + while let Some(ch) = chars.peek() { + match ch { + _ if ch.is_alphabetic() || *ch == '_' => { + var_name.push(*ch); + chars.next(); + } + _ => break + } + } + + tokens.push(Self::Var(var_name)); + } _ => { - return Err(ShErr::simple( - ShErrKind::ParseErr, - "Invalid character in arithmetic substitution", - )); + return Ok(None); } } } - Ok(tokens) + Ok(Some(tokens)) } fn to_rpn(tokens: Vec) -> ShResult> { @@ -733,6 +749,22 @@ impl ArithTk { } } } + ArithTk::Var(var) => { + let Some(val) = read_vars(|v| v.try_get_var(&var)) else { + return Err(ShErr::simple( + ShErrKind::NotFound, + format!("Undefined variable in arithmetic expression: '{}'",var.fg(next_color())), + )); + }; + let Ok(num) = val.parse::() else { + return Err(ShErr::simple( + ShErrKind::ParseErr, + format!("Variable '{}' does not contain a number", var.fg(next_color())), + )); + }; + + output.push(ArithTk::Num(num)); + } } } @@ -812,14 +844,16 @@ impl FromStr for ArithOp { } } -pub fn expand_arithmetic(raw: &str) -> ShResult { +pub fn expand_arithmetic(raw: &str) -> ShResult> { let body = raw.strip_prefix('(').unwrap().strip_suffix(')').unwrap(); // Unwraps are safe here, we already checked for the parens let unescaped = unescape_math(body); let expanded = expand_raw(&mut unescaped.chars().peekable())?; - let tokens = ArithTk::tokenize(&expanded)?; + let Some(tokens) = ArithTk::tokenize(&expanded)? else { + return Ok(None); + }; let rpn = ArithTk::to_rpn(tokens)?; let result = ArithTk::eval_rpn(rpn)?; - Ok(result.to_string()) + Ok(Some(result.to_string())) } pub fn expand_proc_sub(raw: &str, is_input: bool) -> ShResult { @@ -874,7 +908,7 @@ pub fn expand_proc_sub(raw: &str, is_input: bool) -> ShResult { pub fn expand_cmd_sub(raw: &str) -> ShResult { if raw.starts_with('(') && raw.ends_with(')') - && let Ok(output) = expand_arithmetic(raw) + && let Some(output) = expand_arithmetic(raw)? { return Ok(output); // It's actually an arithmetic sub } @@ -1583,11 +1617,19 @@ pub fn glob_to_regex(glob: &str, anchored: bool) -> Regex { if anchored { regex.push('^'); } - for ch in glob.chars() { + let mut chars = glob.chars(); + while let Some(ch) = chars.next() { match ch { + '\\' => { + // Shell escape: next char is literal + if let Some(esc) = chars.next() { + regex.push('\\'); + regex.push(esc); + } + } '*' => regex.push_str(".*"), '?' => regex.push('.'), - '.' | '+' | '(' | ')' | '|' | '^' | '$' | '[' | ']' | '{' | '}' | '\\' => { + '.' | '+' | '(' | ')' | '|' | '^' | '$' | '[' | ']' | '{' | '}' => { regex.push('\\'); regex.push(ch); } diff --git a/src/libsh/error.rs b/src/libsh/error.rs index 2bbb933..a78e6de 100644 --- a/src/libsh/error.rs +++ b/src/libsh/error.rs @@ -86,6 +86,7 @@ pub fn clear_color() { pub trait ShResultExt { fn blame(self, span: Span) -> Self; fn try_blame(self, span: Span) -> Self; + fn promote_err(self, span: Span) -> Self; } impl ShResultExt for Result { @@ -97,6 +98,9 @@ impl ShResultExt for Result { fn try_blame(self, new_span: Span) -> Self { self.map_err(|e| e.try_blame(new_span)) } + fn promote_err(self, span: Span) -> Self { + self.map_err(|e| e.promote(span)) + } } #[derive(Clone, Debug)] @@ -175,6 +179,12 @@ impl ShErr { pub fn simple(kind: ShErrKind, msg: impl Into) -> Self { Self { kind, src_span: None, labels: vec![], sources: vec![], notes: vec![msg.into()], io_guards: vec![] } } + pub fn promote(mut self, span: Span) -> Self { + if let Some(note) = self.notes.pop() { + self = self.labeled(span, note) + } + self + } pub fn with_redirs(mut self, guard: RedirGuard) -> Self { self.io_guards.push(guard); self diff --git a/src/parse/execute.rs b/src/parse/execute.rs index c2f6e61..fd7f2f2 100644 --- a/src/parse/execute.rs +++ b/src/parse/execute.rs @@ -7,7 +7,7 @@ use ariadne::Fmt; use crate::{ builtin::{ - alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, map, pwd::pwd, read::read_builtin, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset}, zoltraak::zoltraak + alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, getopts::getopts, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, map, pwd::pwd, read::read_builtin, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset}, zoltraak::zoltraak }, expand::{expand_aliases, glob_to_regex}, jobs::{ChildProc, JobStack, attach_tty, dispatch_job}, @@ -818,6 +818,7 @@ impl Dispatcher { "rotate" => arr_rotate(cmd), "wait" => jobctl::wait(cmd), "type" => intro::type_builtin(cmd), + "getopts" => getopts(cmd), "true" | ":" => { state::set_status(0); Ok(()) @@ -937,7 +938,6 @@ impl Dispatcher { match unsafe { fork()? } { ForkResult::Child => { let _ = setpgid(Pid::from_raw(0), existing_pgid.unwrap_or(Pid::from_raw(0))); - crate::signal::reset_signals(); f(self); exit(state::get_status()) } diff --git a/src/parse/lex.rs b/src/parse/lex.rs index 6a5937f..08bb39f 100644 --- a/src/parse/lex.rs +++ b/src/parse/lex.rs @@ -1061,11 +1061,13 @@ pub fn lookahead(pat: &str, mut chars: Chars) -> Option { pub fn case_pat_lookahead(mut chars: Peekable) -> Option { let mut pos = 0; while let Some(ch) = chars.next() { - pos += 1; + pos += ch.len_utf8(); match ch { _ if is_hard_sep(ch) => return None, '\\' => { - chars.next(); + if let Some(esc) = chars.next() { + pos += esc.len_utf8(); + } } ')' => return Some(pos), '(' => return None, diff --git a/src/readline/linebuf.rs b/src/readline/linebuf.rs index 5ac0a29..dfe67d5 100644 --- a/src/readline/linebuf.rs +++ b/src/readline/linebuf.rs @@ -2897,7 +2897,7 @@ impl LineBuf { } Verb::Equalize => todo!(), Verb::InsertModeLineBreak(anchor) => { - let (mut start, end) = self.this_line(); + let (mut start, end) = self.this_line_exclusive(); let auto_indent = read_shopts(|o| o.prompt.auto_indent); if start == 0 && end == self.cursor.max { match anchor { diff --git a/src/readline/mod.rs b/src/readline/mod.rs index 92c9648..6f555cd 100644 --- a/src/readline/mod.rs +++ b/src/readline/mod.rs @@ -11,7 +11,7 @@ use crate::libsh::sys::TTY_FILENO; use crate::parse::lex::{LexStream, QuoteState}; use crate::prelude::*; use crate::readline::term::{Pos, calc_str_width}; -use crate::state::read_shopts; +use crate::state::{ShellParam, read_shopts}; use crate::{ libsh::error::ShResult, parse::lex::{self, LexFlags, Tk, TkFlags, TkRule}, @@ -987,8 +987,7 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> { let mut insertions: Vec<(usize, Marker)> = vec![]; if token.class != TkRule::Str - && let Some(marker) = marker_for(&token.class) - { + && let Some(marker) = marker_for(&token.class) { insertions.push((token.span.range().end, markers::RESET)); insertions.push((token.span.range().start, marker)); return insertions; @@ -1082,6 +1081,7 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> { || *br_ch == '=' || *br_ch == '/' // parameter expansion symbols || *br_ch == '?' + || *br_ch == '$' // we're in some expansion like $foo$bar or ${foo$bar} { token_chars.next(); } else if *br_ch == '}' { @@ -1100,7 +1100,9 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> { let mut end_pos = dollar_pos + 1; // consume the var name while let Some((cur_i, var_ch)) = token_chars.peek() { - if var_ch.is_ascii_alphanumeric() || *var_ch == '_' { + if var_ch.is_ascii_alphanumeric() + || ShellParam::from_char(var_ch).is_some() + || *var_ch == '_' { end_pos = *cur_i + 1; token_chars.next(); } else { diff --git a/src/state.rs b/src/state.rs index c5a5e07..e8400ff 100644 --- a/src/state.rs +++ b/src/state.rs @@ -78,6 +78,19 @@ impl ShellParam { Self::Status | Self::ShPid | Self::LastJob | Self::ShellName ) } + + pub fn from_char(c: &char) -> Option { + match c { + '?' => Some(Self::Status), + '$' => Some(Self::ShPid), + '!' => Some(Self::LastJob), + '0' => Some(Self::ShellName), + '@' => Some(Self::AllArgs), + '*' => Some(Self::AllArgsStr), + '#' => Some(Self::ArgCount), + _ => None, + } + } } impl Display for ShellParam { @@ -165,6 +178,9 @@ impl ScopeStack { pub fn cur_scope_mut(&mut self) -> &mut VarTab { self.scopes.last_mut().unwrap() } + pub fn sh_argv(&self) -> &VecDeque { + self.cur_scope().sh_argv() + } pub fn unset_var(&mut self, var_name: &str) -> ShResult<()> { for scope in self.scopes.iter_mut().rev() { if scope.var_exists(var_name) { @@ -1075,6 +1091,8 @@ pub struct MetaTab { // pushd/popd stack dir_stack: VecDeque, + // getopts char offset for opts like -abc + getopts_offset: usize, old_path: Option, old_pwd: Option, @@ -1083,6 +1101,7 @@ pub struct MetaTab { cwd_cache: HashSet, // programmable completion specs comp_specs: HashMap>, + } impl MetaTab { @@ -1092,6 +1111,17 @@ impl MetaTab { ..Default::default() } } + pub fn getopts_char_offset(&self) -> usize { + self.getopts_offset + } + pub fn inc_getopts_char_offset(&mut self) -> usize { + let offset = self.getopts_offset; + self.getopts_offset += 1; + offset + } + pub fn reset_getopts_char_offset(&mut self) { + self.getopts_offset = 0; + } pub fn get_builtin_comp_specs() -> HashMap> { let mut map = HashMap::new();