From baa7b2e5f3c6dab18fc36b1bfb399d8fc638771d Mon Sep 17 00:00:00 2001 From: reuben olinsky Date: Fri, 17 May 2024 13:07:53 -0700 Subject: [PATCH] Implement more builtins (#15) --- cli/tests/cases/builtins/builtin.yaml | 14 ++++ cli/tests/cases/builtins/export.yaml | 5 ++ cli/tests/cases/builtins/let.yaml | 16 +++++ cli/tests/cases/builtins/readonly.yaml | 16 +++++ shell/src/builtins/builtin_.rs | 42 ++++++++++++ shell/src/builtins/declare.rs | 58 +++++++++++----- shell/src/builtins/dot.rs | 2 +- shell/src/builtins/echo.rs | 3 +- shell/src/builtins/exec.rs | 2 +- shell/src/builtins/export.rs | 83 ++++++++++++++++------- shell/src/builtins/{fals.rs => false_.rs} | 0 shell/src/builtins/getopts.rs | 3 +- shell/src/builtins/let_.rs | 41 +++++++++++ shell/src/builtins/mod.rs | 32 +++++---- shell/src/builtins/printf.rs | 3 +- shell/src/builtins/{tru.rs => true_.rs} | 0 shell/src/builtins/{typ.rs => type_.rs} | 0 shell/src/builtins/unimp.rs | 14 +++- shell/src/commands.rs | 12 ++++ shell/src/interp.rs | 1 + 20 files changed, 282 insertions(+), 65 deletions(-) create mode 100644 cli/tests/cases/builtins/builtin.yaml create mode 100644 cli/tests/cases/builtins/let.yaml create mode 100644 cli/tests/cases/builtins/readonly.yaml create mode 100644 shell/src/builtins/builtin_.rs rename shell/src/builtins/{fals.rs => false_.rs} (100%) create mode 100644 shell/src/builtins/let_.rs rename shell/src/builtins/{tru.rs => true_.rs} (100%) rename shell/src/builtins/{typ.rs => type_.rs} (100%) diff --git a/cli/tests/cases/builtins/builtin.yaml b/cli/tests/cases/builtins/builtin.yaml new file mode 100644 index 00000000..460754e1 --- /dev/null +++ b/cli/tests/cases/builtins/builtin.yaml @@ -0,0 +1,14 @@ +name: "Builtins: builtin" +cases: + - name: "builtin with no builtin" + stdin: builtin + + - name: "builtin with unknown builtin" + ignore_stderr: true + stdin: builtin not-a-builtin args + + - name: "valid builtin" + stdin: builtin echo "Hello world" + + - name: "valid builtin with hyphen args" + stdin: builtin echo -e "Hello\nWorld" diff --git a/cli/tests/cases/builtins/export.yaml b/cli/tests/cases/builtins/export.yaml index 4545760e..32126083 100644 --- a/cli/tests/cases/builtins/export.yaml +++ b/cli/tests/cases/builtins/export.yaml @@ -13,3 +13,8 @@ cases: echo "Exporting with new value..." export MY_TEST_VAR="changed value" env | grep MY_TEST_VAR + + - name: "Exporting array" + stdin: | + export arr=(a 1 2) + declare -p arr diff --git a/cli/tests/cases/builtins/let.yaml b/cli/tests/cases/builtins/let.yaml new file mode 100644 index 00000000..6ac54be1 --- /dev/null +++ b/cli/tests/cases/builtins/let.yaml @@ -0,0 +1,16 @@ +name: "Builtins: let" +cases: + - name: "Basic let usage" + stdin: | + let 0; echo "0 => $?" + let 1; echo "1 => $?" + + let 0==0; echo "0==0 => $?" + let 0!=0; echo "0!=0 => $?" + + let 1 0; echo "1 0 => $?" + let 0 1; echo "0 1 => $?" + + - name: "let with assignment" + stdin: | + let x=10; echo "x=10 => $?; x==${x}" diff --git a/cli/tests/cases/builtins/readonly.yaml b/cli/tests/cases/builtins/readonly.yaml new file mode 100644 index 00000000..42a5ff00 --- /dev/null +++ b/cli/tests/cases/builtins/readonly.yaml @@ -0,0 +1,16 @@ +name: "Builtins: readonly" +cases: + - name: "making var readonly" + stdin: | + my_var="value" + readonly my_var + + echo "Invoking declare -p..." + declare -p my_var + + - name: "using readonly with value" + stdin: | + readonly my_var="my_value" + + echo "Invoking declare -p..." + declare -p my_var diff --git a/shell/src/builtins/builtin_.rs b/shell/src/builtins/builtin_.rs new file mode 100644 index 00000000..1ac40bc0 --- /dev/null +++ b/shell/src/builtins/builtin_.rs @@ -0,0 +1,42 @@ +use clap::Parser; +use std::io::Write; + +use crate::{ + builtin::{BuiltinCommand, BuiltinExitCode}, + commands, +}; + +#[derive(Parser)] +pub(crate) struct BuiltiCommand { + builtin_name: Option, + + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +} + +#[async_trait::async_trait] +impl BuiltinCommand for BuiltiCommand { + async fn execute( + &self, + mut context: crate::context::CommandExecutionContext<'_>, + ) -> Result { + if let Some(builtin_name) = &self.builtin_name { + if let Some(builtin) = context.shell.builtins.get(builtin_name) { + context.command_name.clone_from(builtin_name); + + let args: Vec = std::iter::once(builtin_name.into()) + .chain(self.args.iter().map(|arg| arg.into())) + .collect(); + + (builtin.execute_func)(context, args) + .await + .map(|res: crate::builtin::BuiltinResult| res.exit_code) + } else { + writeln!(context.stderr(), "{builtin_name}: command not found")?; + Ok(BuiltinExitCode::Custom(1)) + } + } else { + Ok(BuiltinExitCode::Success) + } + } +} diff --git a/shell/src/builtins/declare.rs b/shell/src/builtins/declare.rs index e6254443..cb1c7d47 100644 --- a/shell/src/builtins/declare.rs +++ b/shell/src/builtins/declare.rs @@ -75,6 +75,13 @@ pub(crate) struct DeclareCommand { declarations: Vec, } +#[derive(Clone, Copy)] +enum DeclareVerb { + Declare, + Local, + Readonly, +} + impl BuiltinDeclarationCommand for DeclareCommand { fn set_declarations(&mut self, declarations: Vec) { self.declarations = declarations; @@ -92,7 +99,11 @@ impl BuiltinCommand for DeclareCommand { &self, mut context: crate::context::CommandExecutionContext<'_>, ) -> Result { - let called_as_local = context.command_name == "local"; + let verb = match context.command_name.as_str() { + "local" => DeclareVerb::Local, + "readonly" => DeclareVerb::Readonly, + _ => DeclareVerb::Declare, + }; // TODO: implement declare -I if self.locals_inherit_from_prev_scope { @@ -106,12 +117,12 @@ impl BuiltinCommand for DeclareCommand { let mut result = BuiltinExitCode::Success; if !self.declarations.is_empty() { for declaration in &self.declarations { - if self.print { - if !self.try_display_declaration(&mut context, declaration, called_as_local)? { + if self.print && !matches!(verb, DeclareVerb::Readonly) { + if !self.try_display_declaration(&mut context, declaration, verb)? { result = BuiltinExitCode::Custom(1); } } else { - if !self.process_declaration(&mut context, declaration, called_as_local)? { + if !self.process_declaration(&mut context, declaration, verb)? { result = BuiltinExitCode::Custom(1); } } @@ -119,11 +130,11 @@ impl BuiltinCommand for DeclareCommand { } else { // Display matching declarations from the variable environment. if !self.function_names_only && !self.function_names_or_defs_only { - self.display_matching_env_declarations(&mut context, called_as_local)?; + self.display_matching_env_declarations(&mut context, verb)?; } // Do the same for functions. - if !called_as_local + if !matches!(verb, DeclareVerb::Local | DeclareVerb::Readonly) && (!self.print || self.function_names_only || self.function_names_or_defs_only) { self.display_matching_functions(&mut context)?; @@ -139,7 +150,7 @@ impl DeclareCommand { &self, context: &mut crate::context::CommandExecutionContext<'_>, declaration: &CommandArg, - called_as_local: bool, + verb: DeclareVerb, ) -> Result { let name = match declaration { CommandArg::String(s) => s, @@ -149,7 +160,7 @@ impl DeclareCommand { } }; - let lookup = if called_as_local { + let lookup = if matches!(verb, DeclareVerb::Local) { EnvironmentLookup::OnlyInCurrentLocal } else { EnvironmentLookup::Anywhere @@ -198,13 +209,13 @@ impl DeclareCommand { &self, context: &mut crate::context::CommandExecutionContext<'_>, declaration: &CommandArg, - called_as_local: bool, + verb: DeclareVerb, ) -> Result { - let create_var_local = - called_as_local || (context.shell.in_function() && !self.create_global); + let create_var_local = matches!(verb, DeclareVerb::Local) + || (context.shell.in_function() && !self.create_global); if self.function_names_or_defs_only || self.function_names_only { - return self.try_display_declaration(context, declaration, called_as_local); + return self.try_display_declaration(context, declaration, verb); } // Extract the variable name and the initial value being assigned (if any). @@ -238,7 +249,7 @@ impl DeclareCommand { var.assign(initial_value, assigned_index.is_some())?; } - self.apply_attributes_after_update(var)?; + self.apply_attributes_after_update(var, verb)?; } else { let unset_type = if self.make_indexed_array.is_some() { ShellValueUnsetType::IndexedArray @@ -258,7 +269,7 @@ impl DeclareCommand { var.assign(initial_value, false)?; } - self.apply_attributes_after_update(&mut var)?; + self.apply_attributes_after_update(&mut var, verb)?; let scope = if create_var_local { EnvironmentScope::Local @@ -351,7 +362,7 @@ impl DeclareCommand { fn display_matching_env_declarations( &self, context: &mut crate::context::CommandExecutionContext<'_>, - called_as_local: bool, + verb: DeclareVerb, ) -> Result<(), error::Error> { // // Dump all declarations. Use attribute flags to filter which variables are dumped. @@ -362,6 +373,11 @@ impl DeclareCommand { let mut filters: Vec bool>> = vec![Box::new(|(_, v)| v.is_enumerable())]; + // Add filters depending on verb. + if matches!(verb, DeclareVerb::Readonly) { + filters.push(Box::new(|(_, v)| v.is_readonly())); + } + // Add filters depending on attribute flags. if let Some(value) = self.make_indexed_array.to_bool() { filters.push(Box::new(move |(_, v)| { @@ -405,7 +421,7 @@ impl DeclareCommand { filters.push(Box::new(move |(_, v)| v.is_exported() == value)); } - let iter_policy = if called_as_local { + let iter_policy = if matches!(verb, DeclareVerb::Local) { EnvironmentLookup::OnlyInCurrentLocal } else { EnvironmentLookup::Anywhere @@ -517,8 +533,14 @@ impl DeclareCommand { } #[allow(clippy::unnecessary_wraps)] - fn apply_attributes_after_update(&self, var: &mut ShellVariable) -> Result<(), error::Error> { - if let Some(value) = self.make_readonly.to_bool() { + fn apply_attributes_after_update( + &self, + var: &mut ShellVariable, + verb: DeclareVerb, + ) -> Result<(), error::Error> { + if matches!(verb, DeclareVerb::Readonly) { + var.set_readonly(); + } else if let Some(value) = self.make_readonly.to_bool() { if value { var.set_readonly(); } else { diff --git a/shell/src/builtins/dot.rs b/shell/src/builtins/dot.rs index c0d59d6e..cb7bb6e7 100644 --- a/shell/src/builtins/dot.rs +++ b/shell/src/builtins/dot.rs @@ -11,7 +11,7 @@ use crate::{ pub(crate) struct DotCommand { pub script_path: String, - #[arg(trailing_var_arg = true)] + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub script_args: Vec, } diff --git a/shell/src/builtins/echo.rs b/shell/src/builtins/echo.rs index 3fd6fc74..affdee8b 100644 --- a/shell/src/builtins/echo.rs +++ b/shell/src/builtins/echo.rs @@ -17,8 +17,7 @@ pub(crate) struct EchoCommand { no_interpret_backslash_escapes: bool, /// Command and args. - #[clap(allow_hyphen_values = true)] - #[arg(trailing_var_arg = true)] + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, } diff --git a/shell/src/builtins/exec.rs b/shell/src/builtins/exec.rs index 948c2d10..f20b4c52 100644 --- a/shell/src/builtins/exec.rs +++ b/shell/src/builtins/exec.rs @@ -18,7 +18,7 @@ pub(crate) struct ExecCommand { exec_as_login: bool, /// Command and args. - #[arg(trailing_var_arg = true)] + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, } diff --git a/shell/src/builtins/export.rs b/shell/src/builtins/export.rs index e11c585d..e57a5621 100644 --- a/shell/src/builtins/export.rs +++ b/shell/src/builtins/export.rs @@ -3,7 +3,8 @@ use itertools::Itertools; use std::io::Write; use crate::{ - builtin::{BuiltinCommand, BuiltinExitCode}, + builtin::{BuiltinCommand, BuiltinDeclarationCommand, BuiltinExitCode}, + commands, env::{EnvironmentLookup, EnvironmentScope}, variables, }; @@ -19,8 +20,19 @@ pub(crate) struct ExportCommand { #[arg(short = 'p')] display_exported_names: bool, - #[arg(name = "name[=value]")] - names: Vec, + // + // Declarations + // + // N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait. + // + #[clap(skip)] + declarations: Vec, +} + +impl BuiltinDeclarationCommand for ExportCommand { + fn set_declarations(&mut self, declarations: Vec) { + self.declarations = declarations; + } } #[async_trait::async_trait] @@ -29,26 +41,51 @@ impl BuiltinCommand for ExportCommand { &self, context: crate::context::CommandExecutionContext<'_>, ) -> Result { - if !self.names.is_empty() { - for name in &self.names { - // See if we have a name=value pair; if so, then update the variable - // with the provided value and then mark it exported. - if let Some((name, value)) = name.split_once('=') { - context.shell.env.update_or_add( - name, - variables::ShellValueLiteral::Scalar(value.to_owned()), - |var| { - var.export(); - Ok(()) - }, - EnvironmentLookup::Anywhere, - EnvironmentScope::Global, - )?; - } else { - // Try to find the variable already present; if we find it, then mark it - // exported. - if let Some((_, variable)) = context.shell.env.get_mut(name) { - variable.export(); + if !self.declarations.is_empty() { + for decl in &self.declarations { + match decl { + commands::CommandArg::String(s) => { + // Try to find the variable already present; if we find it, then mark it + // exported. + if let Some((_, variable)) = context.shell.env.get_mut(s) { + variable.export(); + } + } + commands::CommandArg::Assignment(assignment) => { + let name = match &assignment.name { + parser::ast::AssignmentName::VariableName(name) => name, + parser::ast::AssignmentName::ArrayElementName(_, _) => { + writeln!(context.stderr(), "not a valid variable name")?; + return Ok(BuiltinExitCode::InvalidUsage); + } + }; + + let value = match &assignment.value { + parser::ast::AssignmentValue::Scalar(s) => { + variables::ShellValueLiteral::Scalar(s.flatten()) + } + parser::ast::AssignmentValue::Array(a) => { + variables::ShellValueLiteral::Array(variables::ArrayLiteral( + a.iter() + .map(|(k, v)| { + (k.as_ref().map(|k| k.flatten()), v.flatten()) + }) + .collect(), + )) + } + }; + + // Update the variable with the provided value and then mark it exported. + context.shell.env.update_or_add( + name, + value, + |var| { + var.export(); + Ok(()) + }, + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; } } } diff --git a/shell/src/builtins/fals.rs b/shell/src/builtins/false_.rs similarity index 100% rename from shell/src/builtins/fals.rs rename to shell/src/builtins/false_.rs diff --git a/shell/src/builtins/getopts.rs b/shell/src/builtins/getopts.rs index dd97ca81..1e348189 100644 --- a/shell/src/builtins/getopts.rs +++ b/shell/src/builtins/getopts.rs @@ -16,8 +16,7 @@ pub(crate) struct GetOptsCommand { variable_name: String, /// Arguments to parse - #[clap(allow_hyphen_values = true)] - #[arg(trailing_var_arg = true)] + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, } diff --git a/shell/src/builtins/let_.rs b/shell/src/builtins/let_.rs new file mode 100644 index 00000000..70fb55ee --- /dev/null +++ b/shell/src/builtins/let_.rs @@ -0,0 +1,41 @@ +use clap::Parser; +use std::io::Write; + +use crate::{ + arithmetic::Evaluatable, + builtin::{BuiltinCommand, BuiltinExitCode}, +}; + +#[derive(Parser)] +pub(crate) struct LetCommand { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + exprs: Vec, +} + +#[async_trait::async_trait] +impl BuiltinCommand for LetCommand { + async fn execute( + &self, + context: crate::context::CommandExecutionContext<'_>, + ) -> Result { + let mut exit_code = BuiltinExitCode::InvalidUsage; + + if self.exprs.is_empty() { + writeln!(context.stderr(), "missing expression")?; + return Ok(exit_code); + } + + for expr in &self.exprs { + let parsed = parser::parse_arithmetic_expression(expr.as_str())?; + let evaluated = parsed.eval(context.shell).await?; + + if evaluated == 0 { + exit_code = BuiltinExitCode::Custom(1); + } else { + exit_code = BuiltinExitCode::Custom(0); + } + } + + Ok(exit_code) + } +} diff --git a/shell/src/builtins/mod.rs b/shell/src/builtins/mod.rs index 0c187476..c86ad9b1 100644 --- a/shell/src/builtins/mod.rs +++ b/shell/src/builtins/mod.rs @@ -12,6 +12,7 @@ use crate::error; mod alias; mod bg; mod brea; +mod builtin_; mod cd; mod colon; mod complete; @@ -26,13 +27,14 @@ mod eval; mod exec; mod exit; mod export; -mod fals; +mod false_; mod fg; mod getopts; mod help; mod jobs; #[cfg(unix)] mod kill; +mod let_; mod popd; mod printf; mod pushd; @@ -44,8 +46,8 @@ mod shift; mod shopt; mod test; mod trap; -mod tru; -mod typ; +mod true_; +mod type_; mod umask; mod unalias; mod unimp; @@ -194,18 +196,20 @@ pub(crate) fn get_default_builtins( #[cfg(unix)] m.insert("exec".into(), special_builtin::()); m.insert("exit".into(), special_builtin::()); - m.insert("export".into(), special_builtin::()); // TODO: should be exec_declaration_builtin + m.insert( + "export".into(), + special_decl_builtin::(), + ); m.insert("return".into(), special_builtin::()); m.insert("set".into(), special_builtin::()); m.insert("shift".into(), special_builtin::()); m.insert("trap".into(), special_builtin::()); m.insert("unset".into(), special_builtin::()); - // TODO: Unimplemented special builtins m.insert( "readonly".into(), - special_builtin::(), - ); // TODO: should be exec_declaration_builtin + special_decl_builtin::(), + ); m.insert( "times".into(), special_builtin::(), @@ -218,17 +222,18 @@ pub(crate) fn get_default_builtins( m.insert("alias".into(), builtin::()); // TODO: should be exec_declaration_builtin m.insert("bg".into(), builtin::()); m.insert("cd".into(), builtin::()); - m.insert("false".into(), builtin::()); + m.insert("false".into(), builtin::()); m.insert("fg".into(), builtin::()); m.insert("getopts".into(), builtin::()); m.insert("help".into(), builtin::()); m.insert("jobs".into(), builtin::()); #[cfg(unix)] m.insert("kill".into(), builtin::()); + m.insert("local".into(), decl_builtin::()); m.insert("pwd".into(), builtin::()); m.insert("read".into(), builtin::()); - m.insert("true".into(), builtin::()); - m.insert("type".into(), builtin::()); + m.insert("true".into(), builtin::()); + m.insert("type".into(), builtin::()); m.insert("umask".into(), builtin::()); m.insert("unalias".into(), builtin::()); m.insert("wait".into(), builtin::()); @@ -239,13 +244,12 @@ pub(crate) fn get_default_builtins( m.insert("hash".into(), builtin::()); m.insert("ulimit".into(), builtin::()); - // TODO: does this belong? - m.insert("local".into(), decl_builtin::()); - if !options.sh_mode { + m.insert("builtin".into(), builtin::()); m.insert("declare".into(), decl_builtin::()); m.insert("echo".into(), builtin::()); m.insert("enable".into(), builtin::()); + m.insert("let".into(), builtin::()); m.insert("printf".into(), builtin::()); m.insert("shopt".into(), builtin::()); m.insert("source".into(), special_builtin::()); @@ -265,11 +269,9 @@ pub(crate) fn get_default_builtins( // TODO: Unimplemented builtins m.insert("bind".into(), builtin::()); - m.insert("builtin".into(), builtin::()); m.insert("caller".into(), builtin::()); m.insert("disown".into(), builtin::()); m.insert("history".into(), builtin::()); - m.insert("let".into(), builtin::()); m.insert("logout".into(), builtin::()); m.insert("mapfile".into(), builtin::()); m.insert("readarray".into(), builtin::()); diff --git a/shell/src/builtins/printf.rs b/shell/src/builtins/printf.rs index 6aab6e14..d497a583 100644 --- a/shell/src/builtins/printf.rs +++ b/shell/src/builtins/printf.rs @@ -15,8 +15,7 @@ pub(crate) struct PrintfCommand { format: String, /// Args. - #[clap(allow_hyphen_values = true)] - #[arg(trailing_var_arg = true)] + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, } diff --git a/shell/src/builtins/tru.rs b/shell/src/builtins/true_.rs similarity index 100% rename from shell/src/builtins/tru.rs rename to shell/src/builtins/true_.rs diff --git a/shell/src/builtins/typ.rs b/shell/src/builtins/type_.rs similarity index 100% rename from shell/src/builtins/typ.rs rename to shell/src/builtins/type_.rs diff --git a/shell/src/builtins/unimp.rs b/shell/src/builtins/unimp.rs index d8f664e0..735b522a 100644 --- a/shell/src/builtins/unimp.rs +++ b/shell/src/builtins/unimp.rs @@ -1,4 +1,7 @@ -use crate::builtin::{BuiltinCommand, BuiltinExitCode}; +use crate::{ + builtin::{BuiltinCommand, BuiltinDeclarationCommand, BuiltinExitCode}, + commands, +}; use std::io::Write; use clap::Parser; @@ -7,6 +10,9 @@ use clap::Parser; pub(crate) struct UnimplementedCommand { #[clap(allow_hyphen_values = true)] pub args: Vec, + + #[clap(skip)] + declarations: Vec, } #[async_trait::async_trait] @@ -29,3 +35,9 @@ impl BuiltinCommand for UnimplementedCommand { Ok(BuiltinExitCode::Unimplemented) } } + +impl BuiltinDeclarationCommand for UnimplementedCommand { + fn set_declarations(&mut self, declarations: Vec) { + self.declarations = declarations; + } +} diff --git a/shell/src/commands.rs b/shell/src/commands.rs index d0763b7e..a7f8c60f 100644 --- a/shell/src/commands.rs +++ b/shell/src/commands.rs @@ -16,3 +16,15 @@ impl Display for CommandArg { } } } + +impl From for CommandArg { + fn from(s: String) -> Self { + CommandArg::String(s) + } +} + +impl From<&String> for CommandArg { + fn from(value: &String) -> Self { + CommandArg::String(value.clone()) + } +} diff --git a/shell/src/interp.rs b/shell/src/interp.rs index af3edbdf..f05ef95c 100644 --- a/shell/src/interp.rs +++ b/shell/src/interp.rs @@ -281,6 +281,7 @@ impl Execute for ast::Pipeline { let mut child_future = Box::pin(child.wait_with_output()); // Wait for the process to exit or for a relevant signal, whichever happens first. + // TODO: Figure out how to detect a SIGSTOP'd process. let wait_result = if stopped.is_empty() { loop { tokio::select! {