From 38ebde21f295e23c81b0825f2227921963949ef0 Mon Sep 17 00:00:00 2001 From: Rob Landley Date: Sat, 26 Sep 2026 16:19:36 -0500 Subject: [PATCH] Add $(x@P} and ${X@UuL}, fix ${x^} and friends, and more test organizing. --- tests/sh.test | 240 +++++++++++++++++++++++++++------------------- toys/pending/sh.c | 39 ++++++-- 2 files changed, 172 insertions(+), 107 deletions(-) diff --git a/tests/sh.test b/tests/sh.test index ccd542bd..fac3fbd4 100644 --- a/tests/sh.test +++ b/tests/sh.test @@ -7,120 +7,150 @@ # testing "name" "command" "result" "infile" "stdin" # txpect "name" "command" [R]I/O/E"string" X[ERR] -# We are specifically testing against _bash_, not whatever -# https://wiki.ubuntu.com/DashAsBinSh nonsense the host has /bin/sh point to. +# $BROKEN tests only work on the host +[ -z "$TEST_HOST" ] && : ${BROKEN=true} ${LC_ALL=C.UTF-8} +# Test against _bash_, not whatever https://wiki.ubuntu.com/DashAsBinSh +# nonsense the host has /bin/sh point to. if [ -n "$TEST_HOST" ] && [ -z "$($C --version 2>/dev/null | grep -o bash)" ] then C="$(which bash)" [ -z "$C" ] && { echo "no bash found"; exit 1; } fi -# insulate shell child process to get predictable results -SS="env -i PATH=${PATH@Q} PS1='\\$ ' ${C@Q} --noediting --noprofile --norc -is" -# $BROKEN tests only work on the host -[ -z "$TEST_HOST" ] && : ${BROKEN=true} +# Wrap txpect for shell testing, and run a few basic smoketests -# Wrap txpect for shell testing (Expected prompt different for root/normal user) +# Expected prompt different for root/normal user [ $UID -eq 0 ] && P='# ' || P='$ ' +# insulate shell child process to get predictable results +SS="env -i PATH=${PATH@Q} PS1='\\$ ' ${C@Q} --noediting --noprofile --norc -is" shxpect() { + [ $((SKIP+0)) -gt 0 ] && ((SKIP--)) && return 0 X="$1" shift txpect "$X" "$SS" E"$P" "$@" X0 } - -# shxpect basic smoketests shxpect "prompt and exit" I$'exit\n' shxpect "exit2" I$'exit 42\n' X42 shxpect "prompt/echo/prompt" I$'echo hello\n' O$'hello\n' E"$P" # test command line arguments before setting $EVAL to call -c for us testcmd '-c "" exit status 0' '-c "" && echo $?' '0\n' '' '' -testcmd '-c args' "-c 'echo \$0,\$1,\$2,\$3' one two three four five" \ +testcmd '-c $0 $1 $2 $3' "-c 'echo \$0,\$1,\$2,\$3' one two three four five" \ "one,two,three,four\n" "" "" -testcmd '-c args2' "-c 'echo \${10}' a b c d e f g h i j k l" "k\n" "" "" -testcmd '-c arg split' \ +testcmd '-c ${10}' "-c 'echo \${10}' a b c d e f g h i j k l" "k\n" "" "" +testcmd '-c a"$@"b splits correctly' \ "-c 'for i in a\"\$@\"b;do echo =\$i=;done;echo \$0' 123 456 789" \ "=a456=\n=789b=\n123\n" "" "" -testcmd '-c arg split2' \ +testcmd '-c a"$@ $@"b splits correctly' \ "-c 'for i in a\"\$* \$@\"b; do echo =\$i=;done' one two three four five"\ "=atwo three four five two=\n=three=\n=four=\n=fiveb=\n" "" "" -testcmd '-c arg count' "-c 'echo \$#' 9 8 7 6 1 2 3 4" "7\n" "" "" -testcmd '-c arg shift' \ +testcmd 'IFS $*' "-c 'IFS=xy; echo \"\$*\"' one two tyree" "twoxtyree\n" "" "" +testcmd '-c $# counts' "-c 'echo \$#' 9 8 7 6 1 2 3 4" "7\n" "" "" +testcmd '-c shift' \ "-c '"'for i in "" 2 1 1 1; do echo $? $1; shift $i; done'"' one two three four five" \ "0 two\n0 three\n0 five\n0\n1\n" "" "" -testcmd '-c exec exitval' "-c 'exec echo hello' && echo \$?" "hello\n0\n" "" "" testcmd '-c eval $*' "-c 'eval echo \$*' one two three" "two three\n" "" "" -testcmd 'IFS $*' "-c 'IFS=xy; echo \"\$*\"' one two tyree" "twoxtyree\n" "" "" - -# The bash man page is lying when it says $_ starts with an absolute path. -ln -s "$C" bash -testing 'non-absolute $_' "./bash -c 'echo \$_'" './bash\n' '' '' -rm bash +testcmd '-c exec exitval' "-c 'exec echo hello' && echo \$?" "hello\n0\n" "" "" -# running scripts -testcmd 'simple script' 'input' 'input\n' 'echo $0' '' -testcmd 'simple script2' './input two;echo $?' './input+two\n42\n' \ +# running scripts instead of -c commands +testcmd 'sh script from $PWD, echo $0' 'input' 'input\n' 'echo $0' '' +testcmd 'sh ./script arg, echo $0+$1' './input two;echo $?' './input+two\n42\n'\ '\necho $0+$1\n\nexit 42' '' -testing "script file" "chmod +x input; ./input" "hello\n" "#!$C\necho hello" "" -testcmd 'script $LINENO' 'input' '1\n' 'echo $LINENO' '' -testcmd 'script lineno2' 'input' '5 one\n6 one\n5 two\n6 two\n' \ +testing "#!/script+x" "chmod +x input; ./input" "hello\n" "#!$C\necho hello" "" +mkdir sub; echo echo hello > sub/script +testing 'script in $PATH' "PATH='$PWD/sub:$PATH' $C script" 'hello\n' '' '' +rm -rf sub +testcmd '$LINENO in script' 'input' '1\n' 'echo $LINENO' '' +testcmd '$LINENO in loop in script' 'input' '5 one\n6 one\n5 two\n6 two\n' \ '#!/bin/bash\n\nfor i in one two\ndo\necho $LINENO $i\necho $LINENO $i\ndone\n' "" -toyonly testcmd 'recursion guard (bash segfaults)' \ +toyonly testcmd 'recursion guard avoids segfault' \ 'input 2>/dev/null; [ $? -lt 128 ] && echo pass' 'pass\n' 'source input' '' -mkdir sub -echo echo hello > sub/script -testing 'simple script in $PATH' "PATH='$PWD/sub:$PATH' $C script" \ - 'hello\n' '' '' -rm -rf sub - +# misc -c tests # Without the \n\n bash 5 emits SHLVL=0 -testing 'default exports' "env -i $C --noprofile --norc -c \$'env\n\n' | sort" \ - "PWD=$(pwd)\nSHLVL=1\n_=$(which env)\n" "" "" -# toysh order of operations not matching bash -$BROKEN testing "leading assignment fail" \ - "{ $C -c 'X=\${a?blah} > walroid';ls walroid;} 2>/dev/null" '' '' '' - -# these tests can go after EVAL switch - -# backslash line continuation -testcmd 'trailing \' "-c 'echo \'" '\\\n' '' '' -testcmd "trailing \\ in ''" "-c \$'echo \\'one\\\\\\ntwo\\''" 'one\\\ntwo\n' \ - '' '' -testcmd 'trailing \ in ""' "-c \$'echo \"one\\\\\\ntwo\"'" 'onetwo\n' '' '' -testcmd 'vanishing \' "-c \$'echo \\\\\\n a'" 'a\n' '' '' -testcmd 'command\ arg' "-c \$'echo\\\\\\n abc'" 'abc\n' '' '' - +ln -s "$C" bash +testing 'non-absolute $_ (manpage lies)' "./bash -c 'echo \$_'" './bash\n' '' '' +rm bash testcmd 'syntax' '-c "if true; then echo hello | fi" 2>/dev/null || echo x'\ 'x\n' '' '' testcmd 'syntax2' '-c "for;i 0" 2>&1 | { grep -qi syntax && echo yes; }' \ 'yes\n' '' '' +# Without the \n\n bash 5 emits SHLVL=0 +testing 'default exports' "env -i $C --noprofile --norc -c \$'env\n\n' | sort" \ + "PWD=$(pwd)\nSHLVL=1\n_=$(which env)\n" "" "" -shxpect "redir" I$'cat<< /dev/full\n' E E"$P" X1 -shxpect "wait for <(exit)" I$'cat <(echo hello 1>&2)\n' E$'hello\n' E"$P" +# root and container tests +skipnot [ $UID -eq 0 ] +shxpect I$'echo $LOGNAME\n' O$'root\n' E'# ' +# bash caches hostname at startup, so providing known hostname awkward +skipnot [ $UID -eq 0 -a -n "$(type -t unshare)" ] +toyonly testing "${x@P} hostname" \ + $'unshare -u $C -c \'hostname drift.wood; abc="\h \H \h"; echo ${abc@P}\'' \ + 'drift drift.wood drift\n' '' '' -######################################################################### -# Change EVAL to call sh -c for us, using "bash" explicitly for the host. -export EVAL="timeout 10 $C -c" +# TODO toysh order of operations not matching bash +$BROKEN testing "leading assignment fail" \ + "{ $C -c 'X=\${a?blah} > walroid';ls walroid;} 2>/dev/null" '' '' '' -testing '(subshell)' '(echo hello)' 'hello\n' '' '' # What is this actually testing? #testing "exec3" '$C -c "{ exec readlink /proc/self/fd/0;} < /proc/self/exe"' \ # "$(readlink -f $C)\n" "" "" +######################################################################### +# Change EVAL to call sh -c for us, using "bash" explicitly for the host. # From here on, tests run within the new shell by default. +# TODO: what if $C/PATH/HOME/FILES has spaces in them +export EVAL="timeout 10 env -i PATH=$PATH HOME=$HOME FILES=$FILES $C -c" +testy() { testing "$1" "$2" "$3" '' ''; } + +testy "smoketest" "echo hello" "hello\n" -testing 'return code' 'if false; then echo true; fi; echo $?' '0\n' '' '' -testing 'return code 2' 'if true; then false; fi; echo $?' '1\n' '' '' +# backslash line continuation +testy 'trailing \ is literal' 'echo \' '\\\n' +testy "trailing \ in ''" $'echo \'one\\\ntwo\'' 'one\\\ntwo\n' +testy 'trailing \ in ""' $'echo "one\\\ntwo"' 'onetwo\n' +testy 'trailing \ between args' $'echo \\\n a' 'a\n' +testy 'command\ arg' $'echo\\\n abc' 'abc\n' + +# return values + +testy 'return code' 'if false; then echo true; fi; echo $?' '0\n' +testy 'return code 2' 'if true; then false; fi; echo $?' '1\n' testing 'return code 3' 'x=0; while [ $((x++)) -lt 2 ]; do echo $x; done; echo $?' '1\n2\n0\n' '' '' testing 'return code 4' 'false; A=B; echo $?' '0\n' '' '' + +# misc +testing '(subshell)' '(echo hello)' 'hello\n' '' '' +shxpect "wait for <(exit)" I$'cat <(echo hello 1>&2)\n' E$'hello\n' E"$P" testing 'local var +whiteout' \ 'l=X;x(){ local l=47; echo $l;unset l; echo l=$l;};x;echo $l' '47\nl=\nX\n' \ '' '' testing 'escape passthrough' 'echo -e "a\nb\nc\td"' 'a\nb\nc\td\n' '' '' testing 'trailing $ is literal' 'echo $' '$\n' '' '' + +# redirection + +shxpect "redir" I$'cat<< /dev/full\n' E E"$P" X1 + +testing "redir1" "cat < input" "hello\n" "hello\n" "" +testing "redir2" "echo blah >out; cat out" "blah\n" "" "" +testing "redir3" "echo more >>out; cat out" "blah\nmore\n" "" "" +testing "redir4" "touch /not/exist 2>out||grep -o /not/exist out" \ + "/not/exist\n" "" "" +testing "redir5" "ls out /not/exist &> out2 || wc -l < out2" "2\n" "" "" +testing "redir6" "ls out /not/exist &>>-abc || wc -l < ./-abc" "2\n" "" "" +testing "redir7" "ls out /not/exist |& wc -l" "2\n" "" "" +testing "redir8" 'echo -n $( out 2>/does/not/exist\n' E E"$P" \ + I$'wc -l < out\n' O$'0\n' +testing "redir10" 'echo hello 3<&3' "hello\n" "" "" +testing "redir11" 'if :;then echo one;fi {abc} " I$'0\n' O$'hello\n' testing 'work after HERE' $'cat<<0;echo hello\npotato\n0' 'potato\nhello\n' '' '' testing '<<\EOF' $'(cat< " I$'0\n' O$'hello\n' shxpect '$_ preserved on assignment error' I$'true hello; a=1 b=2 c=${}\n' \ E E"$P" I$'echo $_\n' O$'hello\n' shxpect '$_ preserved on prefix error' I$'true hello; a=1 b=2 c=${} true\n' \ @@ -169,6 +203,12 @@ shxpect '$_ preserved on exec error' I$'true hello; ${}\n' \ shxpect '$_ abspath on exec' I$'env | grep ^_=\n' O$'_=/usr/bin/env\n' testing '$_ literal after exec' 'env >/dev/null; echo $_' 'env\n' '' '' shxpect '$_ no path for builtin' I$'true; echo $_\n' O$'true\n' +testing '$NOTHING clears $_' 'true; $NOTHING; echo $_' '\n' '' '' +testing '$_ with functions' 'true; x(){ echo $_;}; x abc; echo $_' \ + 'true\nabc\n' '' '' + +testing "assignment" 'x=y; echo $x' 'y\n' '' '' +testing "+= assignment" 'x+=abc; y=def; y+=$x; echo $y' 'defabc\n' '' '' testing 'prefix is local for builtins' 'abc=123; abc=def unset abc; echo $abc' \ '123\n' '' '' testing 'prefix localizes magic vars' \ @@ -181,7 +221,6 @@ $BROKEN testing 'prefix readonly' \ 'export poing; readonly poing=abc; { poing=123 env; echo poing=$?;} 2>/dev/null | grep ^poing='\ 'poing=abc\npoing=0\n' '' '' shxpect 'body evaluated before variable exports' I$'a=x${} y${}\n' RE'y${}' X1 -testing '$NOTHING clears $_' 'true; $NOTHING; echo $_' '\n' '' '' testing 'prefix assignment decision made after variable expansion' \ 'abc=def $NOTHING; echo $abc' 'def\n' '' '' testing 'assignment with redirect is persistent, not prefix' \ @@ -197,8 +236,6 @@ testing 'prefix assignment exports' \ testing 'redundant prefix assignment' \ 'ABC=abc; ABC=def ABC=ghi env | grep ^ABC=; echo $ABC' 'ABC=ghi\nabc\n' '' '' -$BROKEN testing '$_ with functions' 'true; x(){ echo $_;}; x abc; echo $_' \ - 'true\nabc\n' '' '' mkdir -p one/two/three testing 'cd in renamed dir' \ @@ -206,9 +243,6 @@ testing 'cd in renamed dir' \ '/four/two\n' '' '' rm -rf one -testing "smoketest" "echo hello" "hello\n" "" "" -testing "assignment" 'x=y; echo $x' 'y\n' '' '' -testing "+= assignment" 'x+=abc; y=def; y+=$x; echo $y' 'defabc\n' '' '' testing "eval" "eval echo hello" "hello\n" "" "" testing "eval2" "eval 'echo hello'; echo $?" "hello\n0\n" "" "" testing "eval3" 'X="echo hello"; eval "$X"' "hello\n" "" "" @@ -218,6 +252,7 @@ $BROKEN testing 'eval6' $'false; eval \'echo $?\'' '1\n' '' '' testing 'eval7' $'eval \'false\'; echo $?' '1\n' '' '' testing 'eval8' $'false; eval ''; echo $?' '0\n' '' '' $BROKEN testing 'eval9' $'A=echo; false; eval \'$A $?\'' '1\n' '' '' + testing "exec" "exec echo hello" "hello\n" "" "" testing "exec2" "exec echo hello; echo $?" "hello\n" "" "" $BROKEN testing "exec redirect persists" 'exec {VAR}< <(echo hello); cat <&$VAR' 'hello\n' '' '' @@ -235,28 +270,11 @@ testing "&& ||" "true && false && potato || echo hello" "hello\n" "" "" testing "&& after function" "x(){ false;};x && echo yes" "" "" "" testing "|| after function" "x(){ false;};x || echo no" "no\n" "" "" -# redirection - -testing "redir1" "cat < input" "hello\n" "hello\n" "" -testing "redir2" "echo blah >out; cat out" "blah\n" "" "" -testing "redir3" "echo more >>out; cat out" "blah\nmore\n" "" "" -testing "redir4" "touch /not/exist 2>out||grep -o /not/exist out" \ - "/not/exist\n" "" "" -testing "redir5" "ls out /not/exist &> out2 || wc -l < out2" "2\n" "" "" -testing "redir6" "ls out /not/exist &>>-abc || wc -l < ./-abc" "2\n" "" "" -testing "redir7" "ls out /not/exist |& wc -l" "2\n" "" "" -testing "redir8" 'echo -n $( out 2>/does/not/exist\n' E E"$P" \ - I$'wc -l < out\n' O$'0\n' -testing "redir10" 'echo hello 3<&3' "hello\n" "" "" -testing "redir11" 'if :;then echo one;fi {abc}A variable occurred @@ -374,7 +416,7 @@ testing "leading variable assignment" 'abc=def env | grep ^abc=; echo $abc' \ testing "leading variable assignments" \ "abc=def ghi=jkl env | egrep '^(abc|ghi)=' | sort; echo \$abc \$ghi" \ "abc=def\nghi=jkl\n\n" "" "" -$BROKEN testing "leading assignment occurs after parsing" \ +testing "leading assignment occurs after parsing" \ 'abc=def; abc=ghi echo $abc' "def\n" "" "" testing "leading assignment space" 'X="abc def"; Y=$X; echo "$Y"' \ "abc def\n" "" "" @@ -397,10 +439,6 @@ testing 'background curly block' \ 'hexxo\nyes\n' '' '' rm -f POIT -$BROKEN testing 'background pipe block' \ - 'if true; then { sleep .25;bzcat "$FILES"/blkid/ntfs.bz2; }& fi | wc -c' \ - '8388608\n' '' '' -$BROKEN testing 'background variable assignment' 'X=x; X=y & echo $X' 'x\n' '' '' #$ IFS=x X=xyxz; for i in abc${X}def; do echo =$i=; done #=abc= @@ -487,10 +525,10 @@ testing "argument splitting" \ testing "assignment nosplit2" 'pop(){ X="$@";};pop one two three; echo $X' \ "one two three\n" "" "" -#testing "leading assignments don't affect current line" \ -# 'VAR=12345 echo ${VAR}a' "a\n" "" "" -#testing "can't have space before first : but yes around arguments" \ -# 'BLAH=abcdefghi; echo ${BLAH: 1 : 3 }' "bcd\n" "" "" +testing "leading assignments don't affect current line" \ + 'VAR=12345 echo ${VAR}a' "a\n" "" "" +testing "can't have space before first : but yes around arguments" \ + 'BLAH=abcdefghi; echo ${BLAH: 1 : 3 }' "bcd\n" "" "" testing "subshell exit err" '(exit 42); echo $?' "42\n" "" "" @@ -513,7 +551,7 @@ $BROKEN testing "pipe into parentheses" \ 'echo hello | (read i sizeof(toybuf) ? sizeof(toybuf) : len); + writeall(2, toybuf, minof(len,sizeof(toybuf))); } #define NO_QUOTE (1<<0) // quote removal @@ -2435,8 +2435,8 @@ barf: push_arg(delete, ifs = xstrdup(ifs)); if (dd != (ll = wctoutf8(buf, ll))) { if (ddarg = xrealloc(ifs, strlen(ifs)+1+dd-ll); - memmove(ifs+yy+dd-ll, ifs+yy+ll, strlen(ifs+yy+ll)+1); + (*delete)->arg = ifs = xrealloc(ifs, strlen(ifs)+1+ll-dd); + memmove(ifs+yy+ll, ifs+yy+dd, strlen(ifs+yy+dd)+1); } memcpy(ss = ifs+yy, buf, dd = ll); } @@ -2504,7 +2504,34 @@ barf: // TODO ${x@QEPAa} Q=$'blah' E=blah without the $'' wrap, P=expand as $PS1 // A=declare that recreates var a=attribute flags // x can be @* -// } else if (*slice=='@') { + // UuLQEPAa + } else if (*slice=='@') { + if ((cc = *++slice)=='P') { + for (ss = 0, xx = strlen(ifs)+1; (ss = xrealloc(ss, xx += 64));) + if (xx>(yy = get_prompt(ss, xx, ifs))) break; + ss[yy] = 0; + push_arg(delete, ifs = ss); + } else if (cc && strchr("UuL", cc)) for (ss = ifs; *ss; ss += dd) { + // TODO can this be merged with ^, logic above? No pattern match... + dd = getutf8(ss, 4, &jj); + if (jj != (ll = (cc=='L' ? towlower : towupper)(jj))) { + yy = ss-ifs; + if (!*delete || (*delete)->arg!=ifs) + push_arg(delete, ifs = xstrdup(ifs)); + if (dd != (ll = wctoutf8(toybuf, ll))) { + if (ddarg = ifs = xrealloc(ifs, strlen(ifs)+1+ll-dd); + memmove(ifs+yy+ll, ifs+yy+dd, strlen(ifs+yy+dd)+1); + } + memcpy(ss = ifs+yy, toybuf, dd = ll); + } + if (cc=='u') break; +// } else if (cc=='Q') { +// for (jj = xx = 0; ifs[jj]; jj++) +//for (s = str+ii; *s != '\''; oo += wcrtomb(new+oo, unescape2(&s, 0),0)); +//ii = s-str+1; + + } else goto fail; // TODO test x can be @ or * } else { -- 2.39.5