Skip to content

Commit 060623b

Browse files
authored
Add printf, read, break, continue, command, function and type builtins (#280)
* Harden shell: eliminate panics, implement all file test operators, fix path resolution - Replace panic!() in parser (for loop wordlist) and types (arithmetic) with proper error handling/fallback behavior - Replace unreachable!() in parser (and_or lists, boolean operators), execute (assignment operators), and main (script/command dispatch) with descriptive errors - Implement all 14 previously unimplemented file test operators: -b (block special), -c (char special), -g (setgid), -k (sticky bit), -p (named pipe), -s (size > 0), -t (terminal fd), -u (setuid), -G (owned by egid), -N (modified since read), -O (owned by euid), -S (socket), -R (nameref), and None (implicit -n) - Add -e (file exists) and -t (terminal fd) to grammar and parser - Fix file test path resolution to use shell CWD instead of process CWD - Fix critical unwrap() calls in main.rs (current_dir), execute.rs (parse result), uname.rs, touch.rs (DST-ambiguous datetimes), set.rs, and mod.rs (clear command) - Add comprehensive test suite for file test operators (3 new test functions) * Add missing bash builtins: break, continue, :, printf, read, command, type Implement 7 new shell builtins for improved bash compatibility: - break/continue: Loop control flow via sentinel exit codes (-100/-101) that propagate through ExecuteResult::Exit and are caught by while/for loops - : (colon): No-op command that always returns 0 - printf: Format string support with %s, %d, %o, %x, %f, %c, %b specifiers, width/precision/flags, and standard escape sequences - read: Read input with -r (raw) and -p (prompt) flags, field splitting, default REPLY variable - command: Run commands bypassing aliases, -v flag for path lookup - type: Describe how a command name resolves (alias, builtin, or PATH) All builtins include comprehensive tests. * Add bash builtins: eval, source, shift, local, return, trap Implement 6 more shell builtins for improved bash compatibility: - eval: Parse and execute string arguments as shell commands - source/.: Fix to use context pipes instead of real stdout/stderr, enabling proper pipe capture in tests and subshells. Read file, parse, and execute in current shell context with env change propagation - shift: Shift positional parameters ($1, $2, ...) left by N - local: Declare shell-scoped variables (foundation for future function support) - return: Exit from sourced scripts with specified exit code via ExecuteResult::Exit propagation - trap: Basic signal handling with -l (list signals), -p (print traps), and handler registration acceptance for compatibility All builtins include comprehensive tests. Total: 46 tests passing. https://claude.ai/code/session_01GTXgqqxHbE7ACysQDZ9cLU * Implement shell function definitions, positional parameters, and brace groups - Add function_definition parsing (foo() { ... }) with FunctionDefinition AST node - Store functions in ShellState and execute via execute_shell_function - Support positional parameters ($1-$N, $#, $@, $*) with save/restore across calls - Fix grammar: UNQUOTED_ESCAPE_CHAR now excludes SPECIAL_PARAM so $1/$2/etc parse correctly - Fix RETURN_EXIT_CODE sentinel handling at top level and in function scope - Sync last_command_exit_code with shell var "?" for proper $? expansion - Add brace group compound command support - Add comprehensive tests for functions, positional params, return values, nested calls * fix lint / fmt * Add e2e test-data files for functions and builtins - functions.sh: tests for function definitions, positional parameters ($1-$N, $#, $@), nested calls, variable scoping, and function overriding - builtins.sh: tests for eval, shift, local, and brace groups
1 parent 7a197ad commit 060623b

File tree

25 files changed

+2399
-94
lines changed

25 files changed

+2399
-94
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/deno_task_shell/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ pest_ascii_tree = "0.1.0"
3030
miette = { version = "7.5.0", features = ["fancy"] }
3131
lazy_static = "1.5.0"
3232

33+
[target.'cfg(unix)'.dependencies]
34+
libc = "0.2"
35+
3336
[dev-dependencies]
3437
tempfile = "3.16.0"
3538
parking_lot = "0.12.3"

crates/deno_task_shell/src/grammar.pest

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,9 @@ FILE_NAME_PENDING_WORD = ${
7777
))+
7878
}
7979

80-
UNQUOTED_ESCAPE_CHAR = ${ ("\\" ~ "$" | "$" ~ !"(" ~ !"{" ~ !VARIABLE) | "\\" ~ (" " | "`" | "\"" | "(" | ")") }
81-
QUOTED_ESCAPE_CHAR = ${ "\\" ~ "$" | "$" ~ !"(" ~ !"{" ~ !(ASCII_DIGIT | VARIABLE) | "\\" ~ ("`" | "\"" | "(" | ")" | "'") }
82-
PARAMETER_ESCAPE_CHAR = ${ "\\" ~ "$" | "$" ~ !"(" ~ !"{" ~ !VARIABLE | "\\" ~ "}" }
80+
UNQUOTED_ESCAPE_CHAR = ${ ("\\" ~ "$" | "$" ~ !"(" ~ !"{" ~ !VARIABLE ~ !SPECIAL_PARAM) | "\\" ~ (" " | "`" | "\"" | "(" | ")") }
81+
QUOTED_ESCAPE_CHAR = ${ "\\" ~ "$" | "$" ~ !"(" ~ !"{" ~ !VARIABLE ~ !SPECIAL_PARAM | "\\" ~ ("`" | "\"" | "(" | ")" | "'") }
82+
PARAMETER_ESCAPE_CHAR = ${ "\\" ~ "$" | "$" ~ !"(" ~ !"{" ~ !VARIABLE ~ !SPECIAL_PARAM | "\\" ~ "}" }
8383

8484
UNQUOTED_CHAR = ${ ("\\" ~ " ") | !("]]" | "[[" | "(" | ")" | "<" | ">" | "|" | "&" | ";" | "\"" | "'" | "$") ~ ANY }
8585
QUOTED_CHAR = ${ !"\"" ~ ANY }
@@ -192,8 +192,8 @@ pipe_sequence = !{ command ~ ((StdoutStderr | Stdout) ~ linebreak ~ pipe_sequenc
192192

193193
command = !{
194194
compound_command ~ redirect_list? |
195-
simple_command |
196-
function_definition
195+
function_definition |
196+
simple_command
197197
}
198198

199199
compound_command = {
@@ -347,8 +347,8 @@ unary_conditional_expression = !{
347347
}
348348

349349
file_conditional_op = !{
350-
"-a" | "-b" | "-c" | "-d" | "-e" | "-f" | "-g" | "-h" | "-k" |
351-
"-p" | "-r" | "-s" | "-u" | "-w" | "-x" | "-G" | "-L" |
350+
"-a" | "-b" | "-c" | "-d" | "-e" | "-f" | "-g" | "-h" | "-k" |
351+
"-p" | "-r" | "-s" | "-t" | "-u" | "-w" | "-x" | "-G" | "-L" |
352352
"-N" | "-O" | "-S"
353353
}
354354

crates/deno_task_shell/src/parser.rs

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ pub enum CommandInner {
170170
Case(CaseClause),
171171
#[error("Invalid arithmetic expression")]
172172
ArithmeticExpression(Arithmetic),
173+
#[error("Invalid function definition")]
174+
Function(FunctionDefinition),
173175
}
174176

175177
impl From<Command> for Sequence {
@@ -238,6 +240,15 @@ pub struct CaseClause {
238240
pub cases: Vec<(Vec<Word>, SequentialList)>,
239241
}
240242

243+
#[cfg_attr(feature = "serialization", derive(serde::Serialize))]
244+
#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))]
245+
#[derive(Debug, PartialEq, Eq, Clone, Error)]
246+
#[error("Invalid function definition")]
247+
pub struct FunctionDefinition {
248+
pub name: String,
249+
pub body: SequentialList,
250+
}
251+
241252
#[cfg_attr(feature = "serialization", derive(serde::Serialize))]
242253
#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))]
243254
#[derive(Debug, PartialEq, Eq, Clone, Error)]
@@ -661,11 +672,16 @@ pub fn parse(input: &str) -> Result<SequentialList> {
661672
miette::Error::new(e.into_miette()).context("Failed to parse input")
662673
})?;
663674

664-
parse_file(pairs.next().unwrap())
675+
parse_file(pairs.next().ok_or_else(|| miette!("Empty parse result"))?)
665676
}
666677

667678
fn parse_file(pairs: Pair<Rule>) -> Result<SequentialList> {
668-
parse_complete_command(pairs.into_inner().next().unwrap())
679+
parse_complete_command(
680+
pairs
681+
.into_inner()
682+
.next()
683+
.ok_or_else(|| miette!("Expected complete command"))?,
684+
)
669685
}
670686

671687
fn parse_complete_command(pair: Pair<Rule>) -> Result<SequentialList> {
@@ -779,11 +795,15 @@ fn parse_term(
779795
fn parse_and_or(pair: Pair<Rule>) -> Result<Sequence> {
780796
assert!(pair.as_rule() == Rule::and_or);
781797
let mut items = pair.into_inner();
782-
let first_item = items.next().unwrap();
798+
let first_item = items
799+
.next()
800+
.ok_or_else(|| miette!("Expected item in and_or list"))?;
783801
let mut current = match first_item.as_rule() {
784802
Rule::ASSIGNMENT_WORD => parse_shell_var(first_item)?,
785803
Rule::pipeline => parse_pipeline(first_item)?,
786-
_ => unreachable!(),
804+
rule => {
805+
return Err(miette!("Unexpected rule in and_or list: {:?}", rule))
806+
}
787807
};
788808

789809
match items.next() {
@@ -796,10 +816,17 @@ fn parse_and_or(pair: Pair<Rule>) -> Result<Sequence> {
796816
let op = match next_item.as_str() {
797817
"&&" => BooleanListOperator::And,
798818
"||" => BooleanListOperator::Or,
799-
_ => unreachable!(),
819+
other => {
820+
return Err(miette!(
821+
"Expected '&&' or '||', got '{}'",
822+
other
823+
))
824+
}
800825
};
801826

802-
let next_item = items.next().unwrap();
827+
let next_item = items.next().ok_or_else(|| {
828+
miette!("Expected expression after boolean operator")
829+
})?;
803830
let next = parse_and_or(next_item)?;
804831
current = Sequence::BooleanList(Box::new(BooleanList {
805832
current,
@@ -910,9 +937,7 @@ fn parse_command(pair: Pair<Rule>) -> Result<Command> {
910937
match inner.as_rule() {
911938
Rule::simple_command => parse_simple_command(inner),
912939
Rule::compound_command => parse_compound_command(inner),
913-
Rule::function_definition => {
914-
Err(miette!("Function definitions are not supported yet"))
915-
}
940+
Rule::function_definition => parse_function_definition(inner),
916941
_ => Err(miette!("Unexpected rule in command: {:?}", inner.as_rule())),
917942
}
918943
}
@@ -1010,7 +1035,7 @@ fn parse_for_loop(pairs: Pair<Rule>) -> Result<ForLoop> {
10101035

10111036
let wordlist = match inner.next() {
10121037
Some(wordlist_pair) => parse_wordlist(wordlist_pair)?,
1013-
None => panic!("Expected wordlist in for loop"),
1038+
None => return Err(miette!("Expected wordlist in for loop")),
10141039
};
10151040

10161041
let body_pair = inner
@@ -1048,9 +1073,7 @@ fn parse_while_loop(pair: Pair<Rule>, is_until: bool) -> Result<WhileLoop> {
10481073
fn parse_compound_command(pair: Pair<Rule>) -> Result<Command> {
10491074
let inner = pair.into_inner().next().unwrap();
10501075
match inner.as_rule() {
1051-
Rule::brace_group => {
1052-
Err(miette!("Unsupported compound command brace_group"))
1053-
}
1076+
Rule::brace_group => parse_brace_group(inner),
10541077
Rule::subshell => parse_subshell(inner),
10551078
Rule::for_clause => {
10561079
let for_loop = parse_for_loop(inner);
@@ -1116,6 +1139,65 @@ fn parse_subshell(pair: Pair<Rule>) -> Result<Command> {
11161139
}
11171140
}
11181141

1142+
fn parse_brace_group(pair: Pair<Rule>) -> Result<Command> {
1143+
let mut items = Vec::new();
1144+
for inner in pair.into_inner() {
1145+
if inner.as_rule() == Rule::compound_list {
1146+
parse_compound_list(inner, &mut items)?;
1147+
}
1148+
}
1149+
Ok(Command {
1150+
inner: CommandInner::Subshell(Box::new(SequentialList { items })),
1151+
redirect: None,
1152+
})
1153+
}
1154+
1155+
fn parse_function_definition(pair: Pair<Rule>) -> Result<Command> {
1156+
let mut inner = pair.into_inner();
1157+
let fname = inner
1158+
.next()
1159+
.ok_or_else(|| miette!("Expected function name"))?;
1160+
let name = fname.as_str().to_string();
1161+
1162+
// Skip linebreak tokens, find function_body
1163+
let function_body = inner
1164+
.find(|p| p.as_rule() == Rule::function_body)
1165+
.ok_or_else(|| miette!("Expected function body"))?;
1166+
1167+
// function_body = compound_command ~ redirect_list?
1168+
let compound_command = function_body
1169+
.into_inner()
1170+
.next()
1171+
.ok_or_else(|| miette!("Expected compound command in function body"))?;
1172+
1173+
// Parse the compound command (usually a brace_group)
1174+
let body_inner = compound_command.into_inner().next().unwrap();
1175+
let mut items = Vec::new();
1176+
match body_inner.as_rule() {
1177+
Rule::brace_group => {
1178+
for inner in body_inner.into_inner() {
1179+
if inner.as_rule() == Rule::compound_list {
1180+
parse_compound_list(inner, &mut items)?;
1181+
}
1182+
}
1183+
}
1184+
_ => {
1185+
return Err(miette!(
1186+
"Unsupported function body type: {:?}",
1187+
body_inner.as_rule()
1188+
));
1189+
}
1190+
}
1191+
1192+
Ok(Command {
1193+
inner: CommandInner::Function(FunctionDefinition {
1194+
name,
1195+
body: SequentialList { items },
1196+
}),
1197+
redirect: None,
1198+
})
1199+
}
1200+
11191201
fn parse_if_clause(pair: Pair<Rule>) -> Result<IfClause> {
11201202
let mut inner = pair.into_inner();
11211203
let condition = inner
@@ -1265,7 +1347,7 @@ fn parse_unary_conditional_expression(pair: Pair<Rule>) -> Result<Condition> {
12651347
}
12661348
},
12671349
Rule::file_conditional_op => match operator.as_str() {
1268-
"-a" => UnaryOp::FileExists,
1350+
"-a" | "-e" => UnaryOp::FileExists,
12691351
"-b" => UnaryOp::BlockSpecial,
12701352
"-c" => UnaryOp::CharSpecial,
12711353
"-d" => UnaryOp::Directory,
@@ -1276,6 +1358,7 @@ fn parse_unary_conditional_expression(pair: Pair<Rule>) -> Result<Condition> {
12761358
"-p" => UnaryOp::NamedPipe,
12771359
"-r" => UnaryOp::Readable,
12781360
"-s" => UnaryOp::SizeNonZero,
1361+
"-t" => UnaryOp::TerminalFd,
12791362
"-u" => UnaryOp::SetUserId,
12801363
"-w" => UnaryOp::Writable,
12811364
"-x" => UnaryOp::Executable,

crates/deno_task_shell/src/shell/command.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ async fn parse_shebang_args(
205205
CommandInner::While(_) => return err_unsupported(text),
206206
CommandInner::ArithmeticExpression(_) => return err_unsupported(text),
207207
CommandInner::Case(_) => return err_unsupported(text),
208+
CommandInner::Function(_) => return err_unsupported(text),
208209
};
209210
if !cmd.env_vars.is_empty() {
210211
return err_unsupported(text);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright 2018-2024 the Deno authors. MIT license.
2+
3+
use futures::future::LocalBoxFuture;
4+
use futures::FutureExt;
5+
6+
use crate::parser;
7+
use crate::shell::execute::execute_sequential_list;
8+
use crate::shell::execute::AsyncCommandBehavior;
9+
10+
use super::ShellCommand;
11+
use super::ShellCommandContext;
12+
use crate::shell::types::ExecuteResult;
13+
14+
pub struct EvalCommand;
15+
16+
impl ShellCommand for EvalCommand {
17+
fn execute(
18+
&self,
19+
context: ShellCommandContext,
20+
) -> LocalBoxFuture<'static, ExecuteResult> {
21+
let input = context.args.join(" ");
22+
if input.is_empty() {
23+
return Box::pin(futures::future::ready(
24+
ExecuteResult::from_exit_code(0),
25+
));
26+
}
27+
28+
async move {
29+
let parsed = match parser::parse(&input) {
30+
Ok(list) => list,
31+
Err(err) => {
32+
let _ = context
33+
.stderr
34+
.clone()
35+
.write_line(&format!("eval: {err}"));
36+
return ExecuteResult::Continue(2, Vec::new(), Vec::new());
37+
}
38+
};
39+
40+
execute_sequential_list(
41+
parsed,
42+
context.state,
43+
context.stdin,
44+
context.stdout,
45+
context.stderr,
46+
AsyncCommandBehavior::Wait,
47+
)
48+
.await
49+
}
50+
.boxed_local()
51+
}
52+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2018-2024 the Deno authors. MIT license.
2+
3+
use futures::future::LocalBoxFuture;
4+
5+
use super::ShellCommand;
6+
use super::ShellCommandContext;
7+
use crate::shell::types::EnvChange;
8+
use crate::shell::types::ExecuteResult;
9+
10+
/// The `local` builtin declares variables with local scope.
11+
/// Since function support is not yet implemented, this behaves like
12+
/// setting a shell variable (not exported). This matches bash behavior
13+
/// where `local` outside a function generates a warning but still works.
14+
pub struct LocalCommand;
15+
16+
impl ShellCommand for LocalCommand {
17+
fn execute(
18+
&self,
19+
context: ShellCommandContext,
20+
) -> LocalBoxFuture<'static, ExecuteResult> {
21+
let mut changes = Vec::new();
22+
23+
for arg in &context.args {
24+
if let Some(equals_index) = arg.find('=') {
25+
let name = &arg[..equals_index];
26+
let value = &arg[equals_index + 1..];
27+
28+
if !is_valid_var_name(name) {
29+
let _ = context.stderr.clone().write_line(&format!(
30+
"local: `{name}': not a valid identifier"
31+
));
32+
return Box::pin(futures::future::ready(
33+
ExecuteResult::Continue(1, Vec::new(), Vec::new()),
34+
));
35+
}
36+
37+
changes.push(EnvChange::SetShellVar(
38+
name.to_string(),
39+
value.to_string(),
40+
));
41+
} else {
42+
// `local VAR` without assignment - declare it with empty value
43+
if !is_valid_var_name(arg) {
44+
let _ = context.stderr.clone().write_line(&format!(
45+
"local: `{arg}': not a valid identifier"
46+
));
47+
return Box::pin(futures::future::ready(
48+
ExecuteResult::Continue(1, Vec::new(), Vec::new()),
49+
));
50+
}
51+
52+
// Only set if not already defined
53+
if context.state.get_var(arg).is_none() {
54+
changes.push(EnvChange::SetShellVar(
55+
arg.to_string(),
56+
String::new(),
57+
));
58+
}
59+
}
60+
}
61+
62+
Box::pin(futures::future::ready(ExecuteResult::Continue(
63+
0,
64+
changes,
65+
Vec::new(),
66+
)))
67+
}
68+
}
69+
70+
fn is_valid_var_name(name: &str) -> bool {
71+
if name.is_empty() {
72+
return false;
73+
}
74+
let mut chars = name.chars();
75+
let first = chars.next().unwrap();
76+
if !first.is_ascii_alphabetic() && first != '_' {
77+
return false;
78+
}
79+
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
80+
}

0 commit comments

Comments
 (0)