From 82a203e22c66ef18bf42631ec267eb1066108b9e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 16:35:50 +0000 Subject: [PATCH 1/6] test(spec): import 1,044 bash-verified cases from the BashBox test suite Copies (script, stdout) pairs from shipfastlabs/bashbox (MIT) into spec_cases/bash/bashbox-*.test.sh, keeping only cases whose recorded output matches host bash. 624 pass and join the bash comparison gate; 420 are marked TODO skips: real gaps where bashkit differs from bash. Attribution in NOTICE; process in knowledge/operations/testing.md. --- NOTICE | 12 + THIRD_PARTY_LICENSES/README.md | 2 +- .../bash/bashbox-ansi-c-quoting.test.sh | 45 + .../bash/bashbox-arithmetic-array.test.sh | 103 ++ .../bash/bashbox-attributes.test.sh | 313 +++++ .../spec_cases/bash/bashbox-base64.test.sh | 10 + .../tests/spec_cases/bash/bashbox-cat.test.sh | 56 + .../spec_cases/bash/bashbox-checksum.test.sh | 30 + .../spec_cases/bash/bashbox-chmod.test.sh | 9 + .../spec_cases/bash/bashbox-comm.test.sh | 24 + .../bash/bashbox-compound-syntax.test.sh | 74 ++ .../tests/spec_cases/bash/bashbox-cut.test.sh | 31 + .../spec_cases/bash/bashbox-date.test.sh | 26 + .../spec_cases/bash/bashbox-declare.test.sh | 414 +++++++ .../tests/spec_cases/bash/bashbox-du.test.sh | 24 + .../spec_cases/bash/bashbox-echo.test.sh | 66 ++ .../bash/bashbox-env-commands.test.sh | 18 + .../spec_cases/bash/bashbox-err-trap.test.sh | 189 +++ .../spec_cases/bash/bashbox-expand.test.sh | 65 + ...ox-filesystem-backends-integration.test.sh | 10 + .../bash/bashbox-find-predicates.test.sh | 41 + .../spec_cases/bash/bashbox-find.test.sh | 72 ++ .../spec_cases/bash/bashbox-fold.test.sh | 69 ++ .../spec_cases/bash/bashbox-grep.test.sh | 143 +++ .../spec_cases/bash/bashbox-head-tail.test.sh | 35 + .../bash/bashbox-interpreter-builtins.test.sh | 533 +++++++++ .../bash/bashbox-interpreter-core.test.sh | 1053 +++++++++++++++++ .../bash/bashbox-just-bash-compat.test.sh | 403 +++++++ .../spec_cases/bash/bashbox-lexer.test.sh | 260 ++++ .../bash/bashbox-line-number.test.sh | 71 ++ .../bash/bashbox-local-scope.test.sh | 263 ++++ .../tests/spec_cases/bash/bashbox-ls.test.sh | 9 + .../bash/bashbox-marker-bytes.test.sh | 50 + .../spec_cases/bash/bashbox-named-fd.test.sh | 186 +++ .../tests/spec_cases/bash/bashbox-nl.test.sh | 44 + .../tests/spec_cases/bash/bashbox-od.test.sh | 26 + .../spec_cases/bash/bashbox-options.test.sh | 101 ++ .../spec_cases/bash/bashbox-parser.test.sh | 235 ++++ .../spec_cases/bash/bashbox-paste.test.sh | 46 + .../spec_cases/bash/bashbox-path-name.test.sh | 34 + .../spec_cases/bash/bashbox-printf.test.sh | 184 +++ .../bash/bashbox-process-substitution.test.sh | 149 +++ .../bash/bashbox-read-names.test.sh | 68 ++ .../spec_cases/bash/bashbox-rmdir.test.sh | 11 + .../bash/bashbox-script-file.test.sh | 73 ++ .../bash/bashbox-secure-http-client.test.sh | 9 + .../spec_cases/bash/bashbox-security.test.sh | 22 + .../tests/spec_cases/bash/bashbox-sed.test.sh | 794 +++++++++++++ .../tests/spec_cases/bash/bashbox-seq.test.sh | 79 ++ .../bash/bashbox-shell-variables.test.sh | 86 ++ .../bash/bashbox-shopt-glob.test.sh | 89 ++ .../bash/bashbox-shopt-pattern.test.sh | 144 +++ .../spec_cases/bash/bashbox-shopt.test.sh | 70 ++ .../spec_cases/bash/bashbox-sleep.test.sh | 68 ++ .../spec_cases/bash/bashbox-sort.test.sh | 559 +++++++++ .../spec_cases/bash/bashbox-stat.test.sh | 19 + .../bash/bashbox-syntax-error.test.sh | 113 ++ .../tests/spec_cases/bash/bashbox-tac.test.sh | 26 + .../spec_cases/bash/bashbox-temp-path.test.sh | 10 + .../tests/spec_cases/bash/bashbox-tr.test.sh | 89 ++ .../bash/bashbox-type-printer.test.sh | 507 ++++++++ .../spec_cases/bash/bashbox-unexpand.test.sh | 115 ++ .../spec_cases/bash/bashbox-uniq.test.sh | 88 ++ .../spec_cases/bash/bashbox-utf8.test.sh | 78 ++ .../tests/spec_cases/bash/bashbox-wc.test.sh | 11 + .../bash/bashbox-word-expander.test.sh | 310 +++++ .../spec_cases/bash/bashbox-xargs.test.sh | 344 ++++++ knowledge/operations/testing.md | 20 + 68 files changed, 9329 insertions(+), 1 deletion(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-ansi-c-quoting.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-arithmetic-array.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-cat.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-checksum.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-chmod.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-cut.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-du.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-env-commands.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-err-trap.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-filesystem-backends-integration.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-head-tail.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-builtins.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-core.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-line-number.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-local-scope.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-ls.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-marker-bytes.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-named-fd.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-nl.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-od.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-options.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-parser.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-paste.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-path-name.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-printf.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-process-substitution.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-read-names.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-rmdir.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-script-file.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-secure-http-client.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-security.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-sed.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-seq.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-shell-variables.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-shopt-glob.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-shopt-pattern.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-shopt.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-sleep.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-sort.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-tac.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-temp-path.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-tr.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-type-printer.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-utf8.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-wc.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-word-expander.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh diff --git a/NOTICE b/NOTICE index 92c390b33..b10cdc157 100644 --- a/NOTICE +++ b/NOTICE @@ -58,6 +58,18 @@ Our jq builtin aims for compatibility with jq's query syntax and behavior. The implementation uses the jaq Rust crates (MIT licensed) rather than jq source code directly. +-------------------------------------------------------------------------------- +BashBox +-------------------------------------------------------------------------------- +Copyright (c) Pushpak Chhajed +Licensed under the MIT License + +Repository: https://github.com/shipfastlabs/bashbox + +The spec cases in crates/bashkit/tests/spec_cases/bash/bashbox-*.test.sh are +copied from BashBox's test suite (script and expected stdout, recorded from +GNU bash). The full MIT license text is in THIRD_PARTY_LICENSES/MIT.txt. + ================================================================================ RUST DEPENDENCIES ================================================================================ diff --git a/THIRD_PARTY_LICENSES/README.md b/THIRD_PARTY_LICENSES/README.md index f67432975..f1d20ed7e 100644 --- a/THIRD_PARTY_LICENSES/README.md +++ b/THIRD_PARTY_LICENSES/README.md @@ -24,7 +24,7 @@ Bashkit's design or whose test case formats have inspired our testing approach. | File | Projects | License Type | |------|----------|--------------| | `APACHE-2.0.txt` | just-bash, Oils | Apache License 2.0 | -| `MIT.txt` | jq, jaq crates, most dependencies | MIT License | +| `MIT.txt` | jq, jaq crates, BashBox spec cases, most dependencies | MIT License | | `LUCENT.txt` | One True AWK | Lucent Public License | ## Full Dependency Licenses diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-ansi-c-quoting.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-ansi-c-quoting.test.sh new file mode 100644 index 000000000..f3c466baa --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-ansi-c-quoting.test.sh @@ -0,0 +1,45 @@ +# BashBox ansi-c-quoting cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_ansi_c_quoting_the_result_is_quoted_text +# the result is quoted text +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=$'a\nb'; echo "$x"; echo "$'q'" '$'"'q'"; echo $"dq $x"; echo $'it\'s' $'a''b' x$'\t'y; touch zz; echo $'z'* $'*' +### expect +a +b +$'q' $'q' +dq a +b +it's ab x y +zz * +### end + +### bashbox_ansi_c_quoting_type_shows_it_single_quoted +# type shows it single-quoted +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() { echo $'a\tb' $'it\'s' "$'x'" $"y"; } +type f +### expect +f is a function +f () +{ + echo 'a b' 'it'\''s' "$'x'" "y" +} +### end + +### bashbox_ansi_c_quoting_here_documents_expand_as_in_double_quotes_quotes_stay +# here-documents expand as in double quotes, quotes stay +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=1; cat <> 70 )) $(( 1 << -1 )) $(( 8 >> -62 )); z=1; ((z <<= 65)); echo $z +### expect +0 -420491770248316829 -9223372036854775808 1 1 -9223372036854775808 -1 -9223372036854775808 2 +2 +### end + +### bashbox_arithmetic_array_let_stops_at_a_failing_expression +# let stops at a failing expression +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +let "1/0" "y=2"; echo $? $y; let; echo $?; let x=1 "y=x+1"; echo $? $y; let 0; echo $? +### expect +1 +1 +0 2 +1 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh new file mode 100644 index 000000000..8c48436ab --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh @@ -0,0 +1,313 @@ +# BashBox attributes cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_attributes_a_plain_assignment_is_not_exported +# a plain assignment is not exported +FOO=1; printenv FOO; echo $? +### expect +1 +### end + +### bashbox_attributes_export_marks_the_name_so_later_values_reach_commands +# export marks the name, so later values reach commands +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +export X=1; X=2; printenv X; export Y; Y=3; printenv Y +### expect +2 +3 +### end + +### bashbox_attributes_a_local_inherits_the_export_attribute +# a local inherits the export attribute +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +export x=g; f(){ local x=1; printenv x; }; f +### expect +1 +### end + +### bashbox_attributes_local_x_exports_only_the_local +# local -x exports only the local +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=g; f(){ local -x x=1; printenv x; }; f; printenv x; echo $? +### expect +1 +1 +### end + +### bashbox_attributes_unset_drops_the_export_attribute +# unset drops the export attribute +export x=1; unset x; x=2; printenv x; echo $? +### expect +1 +### end + +### bashbox_attributes_export_n_un_exports +# export -n un-exports +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +export A=1; export -n A; printenv A; echo $? $A +### expect +1 1 +### end + +### bashbox_attributes_declare_p_shows_r_and_x +# declare -p shows r and x +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -rx R=1; declare -p R; declare -x S; declare -p S; export -p | grep -w '[RS]' +### expect +declare -rx R="1" +declare -x S +declare -rx R="1" +declare -x S +### end + +### bashbox_attributes_declare_x_un_exports +# declare +x un-exports +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -x A=1; declare +x A; printenv A; echo $? +### expect +1 +### end + +### bashbox_attributes_set_a_exports_every_assignment_while_on +# set -a exports every assignment while on +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -a; B=2; printenv B; echo $-; set +a; C=3; printenv C; echo $? +### expect +2 +ahBc +1 +### end + +### bashbox_attributes_allexport_covers_locals_and_read_not_arrays +# allexport covers locals and read, not arrays +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -o allexport; f(){ local l=3; declare -p l; }; f; read r <<< hi; declare -p r; arr=(1); declare -p arr +### expect +declare -x l="3" +declare -x r="hi" +declare -a arr=([0]="1") +### end + +### bashbox_attributes_prefix_assignments_are_exported_for_one_command +# prefix assignments are exported for one command +FOO=1 printenv FOO; printenv FOO; echo $?; f(){ printenv V; }; V=7 f; printenv V; echo $? +### expect +1 +1 +7 +1 +### end + +### bashbox_attributes_a_prefix_assignment_to_an_exported_variable_restores_its_val +# a prefix assignment to an exported variable restores its value +export V=0; V=1 printenv V; printenv V +### expect +1 +0 +### end + +### bashbox_attributes_arrays_print_with_a_or_a_an_associative_one_with_a_trailing_ +# arrays print with a or A, an associative one with a trailing space +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -a arr=(1 2); export arr; declare -p arr; declare -A m=([k]=v); declare -p m +### expect +declare -ax arr=([0]="1" [1]="2") +declare -A m=([k]="v" ) +### end + +### bashbox_attributes_a_local_hides_a_until_the_function_returns +# a local hides -A until the function returns +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -A m=([a]=1); f(){ local m; declare -p m; local -A n; n[x]=1; declare -p n; }; f; declare -p m +### expect +declare -- m +declare -A n=([x]="1" ) +declare -A m=([a]="1" ) +### end + +### bashbox_attributes_declare_rejects_names_that_are_not_identifiers_and_options_a +# declare rejects names that are not identifiers, and options after the names +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare 1x=2 y=3 -z; echo $? $y +### expect +1 3 +### end + +### bashbox_attributes_local_rejects_names_that_are_not_identifiers +# local rejects names that are not identifiers +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ local a-b=1 c=2; echo $? $c; }; f +### expect +1 2 +### end + +### bashbox_attributes_readonly_rejects_names_that_are_not_identifiers +# readonly rejects names that are not identifiers +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +readonly 9=1 r=1; echo $? $r; readonly | grep ' r=' +### expect +1 1 +declare -r r="1" +### end + +### bashbox_attributes_export_rejects_names_that_are_not_identifiers +# export rejects names that are not identifiers +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +export a.b x=1; echo $?; printenv x +### expect +1 +1 +### end + +### bashbox_attributes_export_rejects_unknown_options +# export rejects unknown options +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +export -z; echo $? +### expect +2 +### end + +### bashbox_attributes_declare_assigns_an_array_element +# declare assigns an array element +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare a[1]=x; declare -p a +### expect +declare -a a=([1]="x") +### end + +### bashbox_attributes_readonly_p_shows_each_variable_s_attributes +# readonly -p shows each variable's attributes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +readonly U; export V; readonly V; readonly -p | grep -w '[UV]' +### expect +declare -r U +declare -rx V +### end + +### bashbox_attributes_declare_p_escapes_quotes_dollars_backquotes_and_backslashes +# declare -p escapes quotes, dollars, backquotes and backslashes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x='a"b$c`d\e'; declare -p x; a=("q\""); declare -p a +### expect +declare -- x="a\"b\$c\`d\\e" +declare -a a=([0]="q\"") +### end + +### bashbox_attributes_export_f_only_checks_that_the_functions_exist +# export -f only checks that the functions exist +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ :; }; export -f f; echo $?; export -f nosuch; echo $? +### expect +0 +1 +### end + +### bashbox_attributes_env_sees_only_exported_variables +# env sees only exported variables +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +env | grep -c FOO; FOO=1 env | grep FOO; export E=1; (E=2; printenv E); printenv E; x=$(printenv E); echo $x +### expect +0 +FOO=1 +2 +1 +1 +### end + +### bashbox_attributes_options_end_at +# options end at -- +export -- G=1; printenv G +### expect +1 +### end + +### bashbox_attributes_o_in_a_cluster_takes_the_next_argument_as_its_option_name +# o in a cluster takes the next argument as its option name +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -euo pipefail; echo "[${1-}]" $-; shopt -po pipefail +### expect +[] ehuBc +set -o pipefail +### end + +### bashbox_attributes_several_o_options +# several -o options +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -o errexit -o nounset; echo $-; set +o nounset; echo $- +### expect +ehuBc +ehBc +### end + +### bashbox_attributes_o_at_the_end_of_a_cluster_lists_the_options_after_applying_t +# o at the end of a cluster lists the options after applying the others +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -euo | grep -E '^(errexit|nounset|xtrace) ' +### expect +errexit on +nounset on +xtrace off +### end + +### bashbox_attributes_o_at_the_end_prints_them_as_commands +# +o at the end prints them as commands +set -e +o | grep -E 'errexit|pipefail' +### expect +set -o errexit +set +o pipefail +### end + +### bashbox_attributes_an_unknown_option_name_changes_nothing +# an unknown option name changes nothing +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -o bogus -u; echo $? $- +### expect +2 hBc +### end + +### bashbox_attributes_an_unknown_letter_changes_nothing +# an unknown letter changes nothing +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -ez; echo $? $- +### expect +2 hBc +### end + +### bashbox_attributes_a_bad_option_inside_is_reported_as +# a bad option inside -- is reported as - +set --bogus; echo $? +### expect +2 +### end + +### bashbox_attributes_letters_with_no_effect_here_are_accepted +# letters with no effect here are accepted +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -hkB; echo $? +### expect +0 +### end + +### bashbox_attributes_words_after_the_options_become_positional_parameters +# words after the options become positional parameters +set -e a b; echo "$@"; set -ex -- c d; echo "$@" +### expect +a b +c d +### end + +### bashbox_attributes_with_nothing_after_it_clears_them +# -- with nothing after it clears them +set -- a b; set --; echo $# +### expect +0 +### end + +### bashbox_attributes_a_lone_keeps_them +# a lone - keeps them +set -- a b; set -; echo $# "$@"; set - c; echo "$@" +### expect +2 a b +c +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh new file mode 100644 index 000000000..3887babb1 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh @@ -0,0 +1,10 @@ +# BashBox base64 cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_base64_empty_input_encodes_to_nothing +# empty input encodes to nothing +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '' | base64 +### expect +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-cat.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-cat.test.sh new file mode 100644 index 000000000..2da16f3fe --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-cat.test.sh @@ -0,0 +1,56 @@ +# BashBox cat cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_cat_b_numbers_only_non_blank_lines_and_wins_over_n +# -b numbers only non-blank lines and wins over -n +printf 'a\n\nb\n' | cat -nb +### expect + 1 a + + 2 b +### end + +### bashbox_cat_s_squeezes_blank_runs_across_files +# -s squeezes blank runs across files +printf 'a\n\n' > c1; printf '\n\nb\n' > c2; cat -sn c1 c2 +### expect + 1 a + 2 + 3 b +### end + +### bashbox_cat_t_shows_tabs +# -T shows tabs +printf 'a\tb\n' | cat -T +### expect +a^Ib +### end + +### bashbox_cat_v_shows_control_and_high_bytes +# -v shows control and high bytes +printf 'a\t\001\177\200\211\240\377\n' | cat -v +### expect +a ^A^?M-^@M-^IM- M-^? +### end + +### bashbox_cat_a_is_vet +# -A is -vET +printf 'a\tb\r\n' | cat -A +### expect +a^Ib^M$ +### end + +### bashbox_cat_e_is_ve +# -e is -vE +printf '\ta\r\n' | cat -e +### expect + a^M$ +### end + +### bashbox_cat_t_is_vt +# -t is -vT +printf '\ta\r\n' | cat -t +### expect +^Ia^M +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-checksum.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-checksum.test.sh new file mode 100644 index 000000000..d340a1697 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-checksum.test.sh @@ -0,0 +1,30 @@ +# BashBox checksum cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_checksum_stdin +# stdin +printf 'hello\n' | md5sum +### expect +b1946ac92492d2347c6235b4d2611184 - +### end + +### bashbox_checksum_newline_in_name +# newline in name +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf z > $'n\nl'; md5sum $'n\nl' +### expect +\fbade9e36a3f36d3d676c1b808451dd7 n\nl +### end + +### bashbox_checksum_check_quiet +# check quiet +md5sum --quiet -c good.md5 +### expect +### end + +### bashbox_checksum_check_status_ok +# check status ok +md5sum --status -c good.md5 +### expect +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-chmod.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-chmod.test.sh new file mode 100644 index 000000000..57e662cb6 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-chmod.test.sh @@ -0,0 +1,9 @@ +# BashBox chmod cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_chmod_f_stays_quiet_but_still_fails +# -f stays quiet but still fails +chmod -f 644 nope; ln -s nope dl; chmod -f 644 dl +### expect +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh new file mode 100644 index 000000000..6d94cd6a7 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh @@ -0,0 +1,24 @@ +# BashBox comm cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_comm_suppress_all +# suppress all +comm -123 a b +### expect +### end + +### bashbox_comm_zero_terminated +# zero terminated +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\0b\0' > z1; printf 'b\0c\0' > z2; comm -z z1 z2 | od -c +### expect +0000000 a \0 \t \t b \0 \t c \0 +0000011 +### end + +### bashbox_comm_empty_files +# empty files +printf '' > e; comm e e +### expect +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh new file mode 100644 index 000000000..ec666839c --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh @@ -0,0 +1,74 @@ +# BashBox compound-syntax cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_compound_syntax_until_loop +# until loop +i=0; until (( i >= 3 )); do echo $i; i=$((i+1)); done +### expect +0 +1 +2 +### end + +### bashbox_compound_syntax_c_style_for +# c-style for +for ((i=0; i<6; i+=2)); do echo $i; done +### expect +0 +2 +4 +### end + +### bashbox_compound_syntax_case +# [[ && ( || ) ]] +[[ -n a && ( -z "" || x == y ) ]] && echo yes +### expect +yes +### end + +### bashbox_compound_syntax_case_2 +# [[ || ! ]] +[[ a == b || ! -n "" ]]; echo $?; [[ a == b || -z x ]]; echo $? +### expect +0 +1 +### end + +### bashbox_compound_syntax_bare_word +# [[ bare word ]] +[[ word ]] && echo set; [[ "" ]] || echo empty +### expect +set +empty +### end + +### bashbox_compound_syntax_quoted_and_escaped_parens_inside +# quoted and escaped parens inside $(...) +echo $(echo 'a)' "b)" \) ) +### expect +a) b) ) +### end + +### bashbox_compound_syntax_a_brace_inside_inside +# a brace inside $(...) inside ${...} +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo ${x:-$(echo })} +### expect +} +### end + +### bashbox_compound_syntax_quoted_and_escaped_braces_inside +# quoted and escaped braces inside ${...} +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +y=ab; echo ${x:-"}"} ${x:-\}} ${x:-'a}'} ${y/a/\}} "${y/b/'}'}" +### expect +} } a} }b a} +### end + +### bashbox_compound_syntax_single_quotes_stay_literal_in_a_double_quoted_default_word +# single quotes stay literal in a double-quoted default word +y=1; echo "${x:-'}'}" "${x:-"a b"}" "${x:-'a'"b"}" "${y:+'a'}" +### expect +'}' a b 'a'b 'a' +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-cut.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-cut.test.sh new file mode 100644 index 000000000..9bb3bccd0 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-cut.test.sh @@ -0,0 +1,31 @@ +# BashBox cut cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_cut_fields_with_a_delimiter +# fields with a delimiter +printf 'a:b:c:d\nnodelim\n' | cut -d: -f1,3- +### expect +a:c:d +nodelim +### end + +### bashbox_cut_fields_default_to_tab_delimited +# fields default to tab-delimited +printf 'a\tb\n' | cut -f2 +### expect +b +### end + +### bashbox_cut_separate_delimiter_argument_and_open_start_range +# separate delimiter argument and open start range +echo a:b:c | cut -d : -f -2 +### expect +a:b +### end + +### bashbox_cut_empty_input +# empty input +printf '' | cut -f1 +### expect +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh new file mode 100644 index 000000000..0a00e27d3 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh @@ -0,0 +1,26 @@ +# BashBox date cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_date_default_format +# default format +date -u -d @0 +### expect +Thu Jan 1 00:00:00 UTC 1970 +### end + +### bashbox_date_combined_ud +# combined -ud +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +date -ud @86400; date -ud@86400 +%F +### expect +Fri Jan 2 00:00:00 UTC 1970 +1970-01-02 +### end + +### bashbox_date_parses_date_strings +# parses date strings +date -u -d '2024-02-29 13:05:09' '+%j %H:%M:%S' +### expect +060 13:05:09 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh new file mode 100644 index 000000000..1d2b1da2f --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh @@ -0,0 +1,414 @@ +# BashBox declare cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_declare_i_evaluates_each_assignment_and_adds +# -i evaluates each assignment, and += adds +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -i x=1+2; x+=3; echo $x; x='2*3'; echo $x; x=abc; echo $x; x=; echo $x; declare -p x +### expect +6 +6 +0 +0 +declare -i x="0" +### end + +### bashbox_declare_i_on_an_array_evaluates_each_element_as_it_is_assigned +# -i on an array evaluates each element as it is assigned +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -i a=(1+1 2*3); a+=(5+5); a[7]=3+3; a[7]+=1; declare -p a +### expect +declare -ai a=([0]="2" [1]="6" [2]="10" [7]="7") +### end + +### bashbox_declare_i_reads_and_let_agree +# -i reads and let/(( )) agree +declare -i x; let x=4; ((x=x+1)); echo $x; read x <<< 2+2; echo $x +### expect +5 +4 +### end + +### bashbox_declare_read_and_printf_name_themselves_too +# read and printf name themselves too +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -i x; (read x <<< 1/0); (printf -v x 1/0); echo $? +### expect +1 +### end + +### bashbox_declare_a_subshell_ends_with_status_1 +# a subshell ends with status 1 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +(declare -i x; x=1/0; echo in); echo out $? +### expect +out 1 +### end + +### bashbox_declare_i_removes_the_attribute_and_a_local_does_not_inherit_it +# +i removes the attribute, and a local does not inherit it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -i x=1; f() { local x; x=1+1; echo $x; }; f; declare +i x; x=1+1; echo $x +### expect +1+1 +1+1 +### end + +### bashbox_declare_a_prefix_assignment_ignores_i_l_and_u +# a prefix assignment ignores -i, -l and -u +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -u x; declare -i n; f() { echo "$x $n"; }; x=abc n=1/0 f +### expect +abc 1/0 +### end + +### bashbox_declare_l_and_u_convert_included +# -l and -u convert, += included +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -l x=ABC; x+=DEF; echo $x; declare -u y=abc; echo $y; read y <<< mixed; echo $y; declare -p x y +### expect +abcdef +ABC +MIXED +declare -l x="abcdef" +declare -u y="MIXED" +### end + +### bashbox_declare_l_and_u_replace_each_other_together_neither_changes +# -l and -u replace each other; together neither changes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -l x=AB; declare -u x; echo $x; x=Cd; echo $x; declare -lu x; x=eF; echo $x; declare +u x; x=Gh; declare -p x +### expect +ab +CD +eF +declare -- x="Gh" +### end + +### bashbox_declare_u_applies_to_array_elements +# -u applies to array elements +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -u a=(xa yb); a[3]=zz; a[3]+=q; declare -p a +### expect +declare -au a=([0]="XA" [1]="YB" [3]="ZZQ") +### end + +### bashbox_declare_declare_p_prints_the_attributes_in_bash_s_order +# declare -p prints the attributes in bash's order +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -ilrx x=5; declare -iAx m; declare -nr q=z; declare -p x m q +### expect +declare -irxl x="5" +declare -Aix m +declare -nr q="z" +### end + +### bashbox_declare_declared_but_unset_arrays_and_scalars_print_bare +# declared but unset arrays and scalars print bare +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -a e; declare -A f; declare -i g; declare -p e f g; e+=(q); declare -p e; [[ -v f ]]; echo $? +### expect +declare -a e +declare -A f +declare -i g +declare -a e=([0]="q") +1 +### end + +### bashbox_declare_a_scalar_declared_as_an_array_becomes_element_0 +# a scalar declared as an array becomes element 0 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=1; declare -A x; declare -p x; y=2; declare -a y; declare -p y; declare -a z=v; declare -p z +### expect +declare -A x=([0]="1" ) +declare -a y=([0]="2") +declare -a z=([0]="v") +### end + +### bashbox_declare_converting_between_indexed_and_associative_fails +# converting between indexed and associative fails +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=(1 2); declare -A a; echo $?; declare -A m; declare -a m; echo $?; declare -p a m +### expect +1 +1 +declare -a a=([0]="1" [1]="2") +declare -A m +### end + +### bashbox_declare_with_an_array_value_the_conversion_error_abandons_the_line +# with an array value the conversion error abandons the line +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=(1); declare -A a=([x]=2); echo same +echo next $?; declare -p a +### expect +next 1 +declare -a a=([0]="1") +### end + +### bashbox_declare_declare_with_only_attributes_lists_the_variables_that_have_t +# declare with only attributes lists the variables that have them all +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -i I=2; declare -ia J=(1); declare -A M; declare -ai; declare -A | grep ' M'; declare -i | grep -E '^declare -a?i [IJ]' +### expect +declare -ai J=([0]="1") +declare -A M +declare -i I="2" +declare -ai J=([0]="1") +### end + +### bashbox_declare_a_nameref_reads_writes_and_appends_through_to_its_target +# a nameref reads, writes and appends through to its target +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -n r=a; a=5; echo $r; r=7; echo $a; r+=x; echo $a; echo ${!r}; declare -p r a +### expect +5 +7 +7x +a +declare -n r="a" +declare -- a="7x" +### end + +### bashbox_declare_a_chain_of_namerefs +# a chain of namerefs +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -n r=x; declare -n s=r; s=5; echo $x ${!s} +### expect +5 x +### end + +### bashbox_declare_a_nameref_to_an_element +# a nameref to an element +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -n r=a[1]; r=x; echo $r; declare -p a +### expect +x +declare -a a=([1]="x") +### end + +### bashbox_declare_a_nameref_to_an_array +# a nameref to an array +declare -n r=arr; r=(1 2 3); echo ${r[1]} ${#r[@]} "${!r[@]}"; r[1]=b; declare -p arr +### expect +2 3 0 1 2 +declare -a arr=([0]="1" [1]="b" [2]="3") +### end + +### bashbox_declare_declare_acts_on_the_target_unless_n_is_given +# declare acts on the target unless -n is given +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -n r=x; declare r=5; declare -i r; r=1+1; declare -p r x +### expect +declare -n r="x" +declare -i x="2" +### end + +### bashbox_declare_n_turns_a_nameref_back_into_a_plain_variable +# +n turns a nameref back into a plain variable +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -n r=x; declare +n r; declare -p r +### expect +declare -- r="x" +### end + +### bashbox_declare_a_nameref_declared_without_a_value_takes_the_first_value_ass +# a nameref declared without a value takes the first value assigned +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -n r; r=x; declare -p r; echo "[${!r}]" +### expect +declare -n r="x" +[x] +### end + +### bashbox_declare_a_for_loop_points_a_nameref_at_each_word +# a for loop points a nameref at each word +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -n r; for r in a b; do r=v; done; declare -p a b r +### expect +declare -- a="v" +declare -- b="v" +declare -n r="b" +### end + +### bashbox_declare_local_n_refers_to_the_caller_s_variable +# local -n refers to the caller's variable +f() { local -n r=$1; r=hi; }; f v; echo $v +### expect +hi +### end + +### bashbox_declare_invalid_nameref_targets_are_refused +# invalid nameref targets are refused +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -n r='1x'; echo $?; declare -n r=@; echo $?; f() { local -n r='a b'; }; f; echo $? +### expect +1 +1 +1 +### end + +### bashbox_declare_a_value_that_can_t_be_a_name_stays_and_declare_n_still_succe +# a value that can't be a name stays and declare -n still succeeds +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=1; declare -n x; echo $?; declare -p x; y=foo; declare -n y; declare -p y +### expect +1 +declare -- x="1" +declare -n y="foo" +### end + +### bashbox_declare_in_a_function_a_self_reference_only_warns +# in a function a self reference only warns +f() { local -n r=r; echo $?; }; f +### expect +0 +### end + +### bashbox_declare_a_circular_reference_reads_as_unset_with_a_warning +# a circular reference reads as unset with a warning +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -n a=b; declare -n b=a; echo "${a-u}"; declare -p a b +### expect +u +declare -n a="b" +declare -n b="a" +### end + +### bashbox_declare_assigning_through_a_circular_reference_abandons_the_line +# assigning through a circular reference abandons the line +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -n a=b; declare -n b=a; a=1; echo same +echo next $? +### expect +next 1 +### end + +### bashbox_declare_read_through_a_circular_reference_fails +# read through a circular reference fails +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -n a=b; declare -n b=a; read a <<< x; echo $? +### expect +1 +### end + +### bashbox_declare_a_nameref_local_to_a_function_is_restored +# a nameref local to a function is restored +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +r=global; f() { local -n r=x; r=1; }; f; echo $r $x +### expect +global 1 +### end + +### bashbox_declare_readonly_accepts_only_a_a_and_f +# readonly accepts only -a, -A and -f +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +readonly -i r=1+1; echo $?; readonly -A m=([k]=v); declare -p m; readonly -a m2; declare -p m2 +### expect +2 +declare -Ar m=([k]="v" ) +declare -r m2 +### end + +### bashbox_declare_readonly_a_converts_nothing +# readonly -A converts nothing +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=(1); readonly -A a; echo $?; declare -p a +### expect +0 +declare -ar a=([0]="1") +### end + +### bashbox_declare_export_and_readonly_assign_array_operands +# export and readonly assign array operands +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +export a=(1 2); readonly b=(3); declare -p a b +### expect +declare -ax a=([0]="1" [1]="2") +declare -ar b=([0]="3") +### end + +### bashbox_declare_declare_p_quotes_control_characters_and_bytes_that_aren_t_ut +# declare -p quotes control characters and bytes that aren't UTF-8 like bash +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=$'a\x01b\e\'"\\c$`d\a\b\f\v\r\t\n\x7f'; declare -p x; y=$'\xc3\xa9\n'; z=$'\xff'; declare -p y z; a=($'x\ny' z); declare -p a +### expect +declare -- x=$'a\001b\E\'"\\c$`d\a\b\f\v\r\t\n\177' +declare -- y=$'é\n' +declare -- z=$'\377' +declare -a a=([0]=$'x\ny' [1]="z") +### end + +### bashbox_declare_associative_keys_with_shell_metacharacters_are_quoted +# associative keys with shell metacharacters are quoted +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -A m=(["a b"]=1) n=(['$x']=2) o=([c]=3); declare -p m n o +### expect +declare -A m=(["a b"]="1" ) +declare -A n=(["\$x"]="2" ) +declare -A o=([c]="3" ) +### end + +### bashbox_declare_declare_p_with_no_names_lists_every_variable +# declare -p with no names lists every variable +unset PIPESTATUS; x=1; declare -p | grep -E '^declare -- x=' +### expect +declare -- x="1" +### end + +### bashbox_declare_plain_declare_lists_like_set +# plain declare lists like set +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=1; declare | grep '^x=' +### expect +x=1 +### end + +### bashbox_declare_printf_v_assigns_the_output_newlines_and_all +# printf -v assigns the output, newlines and all +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf -v x '%s-%s\n' a b; echo "[$x]"; printf -vy %s a; echo $y; printf -v 'a[1+1]' %s z; declare -p a +### expect +[a-b +] +a +declare -a a=([2]="z") +### end + +### bashbox_declare_printf_v_takes_and_a_local +# printf -v takes -- and a local +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf -v x -- %s a; echo $x; f() { local y; printf -v y %s in; echo $y; }; f; echo ${y-unset} +### expect +a +in +unset +### end + +### bashbox_declare_printf_v_errors +# printf -v errors +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf -v; echo $?; printf -v x; echo $? ${x-unset}; printf -v 1x %s a; echo $?; printf -v 'a[' %s x; echo $? +### expect +2 +2 unset +2 +2 +### end + +### bashbox_declare_printf_v_into_a_readonly_variable_fails +# printf -v into a readonly variable fails +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +readonly r; printf -v r %s a; echo $? +### expect +1 +### end + +### bashbox_declare_printf_without_v_is_the_printf_command +# printf without -v is the printf command +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf -- -v; echo +### expect +-v +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-du.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-du.test.sh new file mode 100644 index 000000000..9f6a6e998 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-du.test.sh @@ -0,0 +1,24 @@ +# BashBox du cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_du_human_rounding +# human rounding +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +for n in 1 1023 1024 1025 1536 10239 10240 10241 1047552 1047553 1048575 1048576 1048577 5000000; do printf "%${n}s" '' > f; du -bh f; done +### expect +1 f +1023 f +1.0K f +1.1K f +1.5K f +10K f +10K f +11K f +1023K f +1.0M f +1.0M f +1.0M f +1.1M f +4.8M f +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh new file mode 100644 index 000000000..4650cba79 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh @@ -0,0 +1,66 @@ +# BashBox echo cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_echo_joins_arguments +# joins arguments +echo a b +### expect +a b +### end + +### bashbox_echo_escapes_are_literal_by_default +# escapes are literal by default +echo 'a\tb' +### expect +a\tb +### end + +### bashbox_echo_e_expands_escapes +# -e expands escapes +echo -e 'a\tb\\c\n' +### expect +a b\c + +### end + +### bashbox_echo_e_unknown_escape_and_trailing_backslash_stay +# -e unknown escape and trailing backslash stay +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo -e '\z\xg\' +### expect +\z\xg\ +### end + +### bashbox_echo_e_hex +# -e hex +echo -e '\x41\x4a2' +### expect +AJ2 +### end + +### bashbox_echo_e_c_stops_output_and_the_newline +# -e \c stops output and the newline +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo -e 'a\cb'; echo -e x +### expect +ax +### end + +### bashbox_echo_last_of_e_e_wins +# last of -e/-E wins +echo -eE 'a\tb'; echo -Ee 'a\tb' +### expect +a\tb +a b +### end + +### bashbox_echo_options_stop_at_the_first_non_option +# options stop at the first non-option +echo -nx; echo -- -n; echo -; echo a -n +### expect +-nx +-- -n +- +a -n +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-env-commands.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-env-commands.test.sh new file mode 100644 index 000000000..c7f5fb969 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-env-commands.test.sh @@ -0,0 +1,18 @@ +# BashBox env-commands cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_env_commands_i_starts_empty +# -i starts empty +env -i X=1; env - Y=2 +### expect +X=1 +Y=2 +### end + +### bashbox_env_commands_the_command_keeps_its_options_and_stdin +# the command keeps its options and stdin +echo hi | env -i cat -n +### expect + 1 hi +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-err-trap.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-err-trap.test.sh new file mode 100644 index 000000000..afc0282ad --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-err-trap.test.sh @@ -0,0 +1,189 @@ +# BashBox err-trap cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_err_trap_functions_do_not_inherit_the_err_trap +# functions do not inherit the ERR trap +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap 'echo err' ERR; f(){ false; echo in; }; f; echo out +### expect +in +out +### end + +### bashbox_err_trap_the_failing_call_still_fires_it +# the failing call still fires it +trap 'echo err' ERR; f(){ g; }; g(){ false; }; f; echo end +### expect +err +end +### end + +### bashbox_err_trap_set_e_passes_it_to_functions +# set -E passes it to functions +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -E; trap 'echo err $LINENO' ERR; f(){ false; }; f; echo $-; set +E; f; echo end +### expect +err 1 +err 1 +hBEc +err 1 +end +### end + +### bashbox_err_trap_a_trap_set_in_a_function_stays +# a trap set in a function stays +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ trap 'echo ferr $LINENO' ERR; false; } +f +f +false +echo end +### expect +ferr 1 +ferr 1 +ferr 3 +ferr 4 +end +### end + +### bashbox_err_trap_the_call_fires_only_a_trap_set_before_it +# the call fires only a trap set before it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ trap 'echo t' ERR; return 1; }; f; false; echo end +### expect +t +end +### end + +### bashbox_err_trap_eval_fires_the_trap_it_found +# eval fires the trap it found +trap 'echo a' ERR; eval false; eval "trap 'echo b' ERR; false"; echo end +### expect +a +a +b +b +end +### end + +### bashbox_err_trap_conditions_do_not_fire_it +# conditions do not fire it +trap 'echo err' ERR; if false; then :; elif false; then :; fi; while false; do :; done; until true; do :; done; echo end +### expect +end +### end + +### bashbox_err_trap_tested_function_calls_do_not_fire_it +# tested function calls do not fire it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap 'echo err' ERR; f(){ false; echo in; }; f || true; f && true; ! f; if f; then :; fi; echo end +### expect +in +in +in +in +end +### end + +### bashbox_err_trap_and_or_lists_fire_only_for_the_last +# and-or lists fire only for the last +trap 'echo err' ERR; false && true; false || false; echo end +### expect +err +end +### end + +### bashbox_err_trap_compound_commands_fire_only_through_their_commands +# compound commands fire only through their commands +trap 'echo err' ERR; { false; }; if true; then false; fi; for i in 1; do false; done; case x in x) false;; esac; echo end +### expect +err +err +err +err +end +### end + +### bashbox_err_trap_a_failing_subshell_fires_it_once +# a failing subshell fires it once +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap 'echo err' ERR; (false); (false; true); echo end +### expect +err +end +### end + +### bashbox_err_trap_set_e_reaches_subshells +# set -E reaches subshells +set -E; trap 'echo err' ERR; (false; true); echo end +### expect +err +end +### end + +### bashbox_err_trap_command_substitution_drops_the_err_trap +# command substitution drops the ERR trap +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap 'echo err' ERR; x=$(false; echo in); echo "[$x]"; f(){ false; }; y=$(f); echo "[$y]" +### expect +[in] +err +[] +### end + +### bashbox_err_trap_set_e_keeps_it_in_command_substitution +# set -E keeps it in command substitution +set -E; trap 'echo err' ERR; x=$(false; echo in); echo "[$x]" +### expect +[err +in] +### end + +### bashbox_err_trap_arithmetic_and_fire_it +# arithmetic and [[ ]] fire it +trap 'echo err' ERR; [[ 1 = 2 ]]; (( 0 )); echo end +### expect +err +err +end +### end + +### bashbox_err_trap_errexit_ignores_conditions +# errexit ignores conditions +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -e; if false; then :; fi; while false; do :; done; f(){ false; echo in; }; f || true; f && true; ! f; echo end +### expect +in +in +in +end +### end + +### bashbox_err_trap_set_t_passes_the_return_trap_to_functions +# set -T passes the RETURN trap to functions +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -T; echo $-; trap 'echo r' RETURN; g(){ :; }; f(){ g; }; f; echo end +### expect +hBTc +r +r +end +### end + +### bashbox_err_trap_without_t_it_stays_out_of_nested_calls +# without -T it stays out of nested calls +trap 'echo r' RETURN; g(){ :; }; f(){ g; }; f; echo end +### expect +end +### end + +### bashbox_err_trap_set_o_lists_the_options +# set -o lists the options +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -o | grep -E "errtrace|functrace"; set -E; set +o | grep errtrace +### expect +errtrace off +functrace off +set -o errtrace +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh new file mode 100644 index 000000000..858120c47 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh @@ -0,0 +1,65 @@ +# BashBox expand cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_expand_past_the_last_stop_is_one_space +# past the last stop is one space +printf 'a\tb\tc\td\n' | expand -t 2,4 +### expect +a b c d +### end + +### bashbox_expand_extend +# extend +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\tb\tc\td\te\n' | expand -t 2,5,/3 +### expect +a b c d e +### end + +### bashbox_expand_increment +# increment +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\tb\tc\td\te\n' | expand -t 2,5,+3 +### expect +a b c d e +### end + +### bashbox_expand_only_extend +# only extend +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\tb\n' | expand -t /3 +### expect +a b +### end + +### bashbox_expand_only_increment +# only increment +printf 'a\tb\n' | expand -t +3 +### expect +a b +### end + +### bashbox_expand_repeated_t_adds_stops +# repeated -t adds stops +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\tb\tc\n' | expand -t 2 -t 6 +### expect +a b c +### end + +### bashbox_expand_stdin +# stdin +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '\tz\n' | expand -t 3 - +### expect + z +### end + +### bashbox_expand_slash_then_value_later +# slash then value later +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\tb\tc\n' | expand -t /,4 +### expect +a b c +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-filesystem-backends-integration.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-filesystem-backends-integration.test.sh new file mode 100644 index 000000000..bac1198f2 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-filesystem-backends-integration.test.sh @@ -0,0 +1,10 @@ +# BashBox filesystem-backends-integration cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_filesystem_backends_integration_a_file_that_may_not_be_created +# a file that may not be created +echo x > ro/new; echo $? +### expect +1 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh new file mode 100644 index 000000000..752565146 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh @@ -0,0 +1,41 @@ +# BashBox find-predicates cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_find_predicates_newer_with_h_follows_the_reference +# -newer with -H follows the reference +find -H t/fl -newer t/fl +### expect +### end + +### bashbox_find_predicates_inum_0 +# -inum 0 +find t -inum 0 +### expect +### end + +### bashbox_find_predicates_user_root +# -user root +find t -user root +### expect +### end + +### bashbox_find_predicates_nouser_and_nogroup +# -nouser and -nogroup +find t -nouser -o -nogroup +### expect +### end + +### bashbox_find_predicates_newermt_is_strictly_newer +# -newermt is strictly newer +find t -newermt '2020-01-02 03:04:05' -name f +### expect +### end + +### bashbox_find_predicates_fprint_creates_the_file_even_without_matches +# -fprint creates the file even without matches +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo old > out; find t -name nomatch -fprint out; wc -c < out +### expect +0 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh new file mode 100644 index 000000000..2c5b4e92d --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh @@ -0,0 +1,72 @@ +# BashBox find cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_find_name_is_case_sensitive +# -name is case sensitive +find . -name "main.php" +### expect +### end + +### bashbox_find_file_start_point_filtered_out +# file start point filtered out +find a.txt -type d +### expect +### end + +### bashbox_find_fractional_mtime +# fractional -mtime +find proj -type f -mtime -1.5 -mtime +0.5 +### expect +### end + +### bashbox_find_mmin_n_matches_the_minute_before +# -mmin N matches the minute before +find proj -type f -mmin 30 -o -type f -mmin 5 +### expect +### end + +### bashbox_find_perm_symbolic +# -perm symbolic +find proj ! -type l -perm u=rw,go=r -name 's*' +### expect +### end + +### bashbox_find_perm_copying_another_class +# -perm copying another class +find proj ! -type l -perm u=rw,g=u,o=u +### expect +### end + +### bashbox_find_perm_with_nothing +# -perm = with nothing +find proj ! -type l -perm = +### expect +### end + +### bashbox_find_perm_t_and_t +# -perm =t and +t +find proj -perm +t +### expect +### end + +### bashbox_find_quit_alone_prints_nothing +# -quit alone prints nothing +find proj -quit +### expect +### end + +### bashbox_find_execdir_from_the_root +# -execdir from the root +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +find / -maxdepth 0 -execdir echo {} \; +### expect +/ +### end + +### bashbox_find_printf_h_and_f_of_the_root +# -printf %h and %f of the root +find / -maxdepth 0 -printf '[%h][%f]\n' +### expect +[][/] +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh new file mode 100644 index 000000000..a9ec6dbde --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh @@ -0,0 +1,69 @@ +# BashBox fold cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_fold_default_width_80 +# default width 80 +printf '%0100d\n' 0 | fold +### expect +00000000000000000000000000000000000000000000000000000000000000000000000000000000 +00000000000000000000 +### end + +### bashbox_fold_spaces_long_word +# spaces long word +printf 'abcdefghijklmnop qr\n' | fold -s -w 5 +### expect +abcde +fghij +klmno +p qr +### end + +### bashbox_fold_backspace_at_column_0 +# backspace at column 0 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '\x08abcde\n' | fold -w 3 +### expect +abc +de +### end + +### bashbox_fold_tab_wider_than_width +# tab wider than width +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '\tab\n' | fold -w 4 +### expect + +ab +### end + +### bashbox_fold_space_overflows_the_line +# space overflows the line +printf 'abcde fgh\n' | fold -s -w 5 +### expect +abcde + fgh +### end + +### bashbox_fold_line_ending_in_a_blank +# line ending in a blank +printf 'ab cd ef gh ij\n' | fold -s -w 3 +### expect +ab +cd +ef +gh +ij +### end + +### bashbox_fold_tab_after_break_with_s +# tab after break with -s +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'ab\tcdefghij\n' | fold -s -w 6 +### expect +ab + +cdefgh +ij +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh new file mode 100644 index 000000000..3a82b833a --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh @@ -0,0 +1,143 @@ +# BashBox grep cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_grep_stdin +# stdin +printf 'apple\nbanana\napricot' | grep ap +### expect +apple +apricot +### end + +### bashbox_grep_h_names_stdin +# -H names stdin +echo apple | grep -Hc apple +### expect +(standard input):1 +### end + +### bashbox_grep_quiet +# quiet +grep -q cherry a.txt +### expect +### end + +### bashbox_grep_quiet_stops_at_first_match +# quiet stops at first match +grep -q apple a.txt nope +### expect +### end + +### bashbox_grep_only_matching_skips_empty_matches +# only matching skips empty matches +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo aaa | grep -o 'b*' +### expect +### end + +### bashbox_grep_only_matching_with_v_prints_nothing +# only matching with -v prints nothing +grep -ov an a.txt +### expect +### end + +### bashbox_grep_pattern_starting_with +# pattern starting with - +echo x-v | grep -e -v +### expect +x-v +### end + +### bashbox_grep_newline_separates_patterns +# newline separates patterns +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\nc\n' | grep "$(printf 'a\nc')" +### expect +a +c +### end + +### bashbox_grep_fixed_strings +# fixed strings +printf 'a.c\nabc\n' | grep -F 'a.c' +### expect +a.c +### end + +### bashbox_grep_word_match +# word match +printf 'pie\npies\n_pie\n' | grep -w pie +### expect +pie +### end + +### bashbox_grep_word_match_with_alternation +# word match with alternation +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'ab\nxa\nb\n' | grep -wE 'a|b' +### expect +b +### end + +### bashbox_grep_word_match_with_fixed_string +# word match with fixed string +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'foo.bar\nfoo.\n' | grep -wF 'foo.' +### expect +foo. +### end + +### bashbox_grep_bre_is_literal +# BRE + is literal +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a+b\nab\n' | grep 'a+b' +### expect +a+b +### end + +### bashbox_grep_bre_alternation_and_groups +# BRE alternation and groups +printf 'cat\ndog\ncow\n' | grep '^\(cat\|dog\)$' +### expect +cat +dog +### end + +### bashbox_grep_bre_back_reference +# BRE back-reference +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abab\nabba\n' | grep '\(ab\)\1' +### expect +abab +### end + +### bashbox_grep_bre_leading_star_is_literal +# BRE leading star is literal +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '*a\nb\n' | grep '*a' +### expect +*a +### end + +### bashbox_grep_ere +# ERE +printf 'aa\na\n' | grep -E '^a{2}$' +### expect +aa +### end + +### bashbox_grep_g_resets_to_bre +# -G resets to BRE +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a|b\na\n' | grep -G 'a|b' +### expect +a|b +### end + +### bashbox_grep_slash_in_pattern +# slash in pattern +echo 'a/b' | grep 'a/b' +### expect +a/b +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-head-tail.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-head-tail.test.sh new file mode 100644 index 000000000..5a0f4e20a --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-head-tail.test.sh @@ -0,0 +1,35 @@ +# BashBox head-tail cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_head_tail_head_defaults_to_10_lines +# head defaults to 10 lines +seq 12 | head +### expect +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +### end + +### bashbox_head_tail_tail_defaults_to_10_lines +# tail defaults to 10 lines +seq 12 | tail +### expect +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-builtins.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-builtins.test.sh new file mode 100644 index 000000000..e56258158 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-builtins.test.sh @@ -0,0 +1,533 @@ +# BashBox interpreter-builtins cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_interpreter_builtins_for_without_in_walks_the_positional_parameters +# for without in walks the positional parameters +set -- a b c; for x; do echo $x; done +### expect +a +b +c +### end + +### bashbox_interpreter_builtins_c_style_for_with_continue_and_break +# c-style for with continue and break +for ((i=0; i<10; i++)); do test $i = 1 && continue; test $i = 3 && break; echo $i; done +### expect +0 +2 +### end + +### bashbox_interpreter_builtins_c_style_for_with_no_clauses_loops_until_break +# c-style for with no clauses loops until break +i=0; for ((;;)); do i=$((i+1)); test $i -gt 2 && break; echo $i; done +### expect +1 +2 +### end + +### bashbox_interpreter_builtins_loop_status_is_the_last_body_status +# loop status is the last body status +for ((i=0; i<2; i++)); do false; done; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_while_with_continue_and_break +# while with continue and break +i=0; while test $i -lt 5; do i=$((i+1)); test $i = 2 && continue; test $i = 4 && break; echo $i; done +### expect +1 +3 +### end + +### bashbox_interpreter_builtins_while_break_2_from_a_nested_while +# while break 2 from a nested while +for a in x y; do while true; do while true; do break 2; done; done; echo $a; done +### expect +x +y +### end + +### bashbox_interpreter_builtins_while_continue_3_resumes_the_outer_for +# while continue 3 resumes the outer for +for a in x y; do while true; do while true; do continue 3; done; done; echo no; done; echo done +### expect +done +### end + +### bashbox_interpreter_builtins_while_that_never_runs_exits_0 +# while that never runs exits 0 +while false; do :; done; echo $? +### expect +0 +### end + +### bashbox_interpreter_builtins_until_counts_up +# until counts up +i=0; until test $i -ge 3; do echo $i; i=$((i+1)); done +### expect +0 +1 +2 +### end + +### bashbox_interpreter_builtins_until_with_continue_and_break +# until with continue and break +i=0; until false; do i=$((i+1)); test $i = 2 && continue; test $i = 4 && break; echo $i; done +### expect +1 +3 +### end + +### bashbox_interpreter_builtins_until_status_is_the_last_body_status +# until status is the last body status +i=0; until test $i = 2; do i=$((i+1)); false; done; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_runs_the_next_body_without_testing_it +# ;& runs the next body without testing it +case a in a) echo 1;& b) echo 2;; c) echo 3;; esac +### expect +1 +2 +### end + +### bashbox_interpreter_builtins_goes_on_testing_the_next_patterns +# ;;& goes on testing the next patterns +case ab in a*) echo 1;;& *b) echo 2;;& c) echo 3;; esac +### expect +1 +2 +### end + +### bashbox_interpreter_builtins_no_match_exits_0 +# no match exits 0 +case x in y) echo no;; esac; echo $? +### expect +0 +### end + +### bashbox_interpreter_builtins_status_of_the_matched_body +# status of the matched body +case x in x) false;; esac; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_quoted_backslash +# quoted backslash +case 'a\' in 'a\') echo bs;; esac +### expect +bs +### end + +### bashbox_interpreter_builtins_unclosed_bracket_is_literal +# unclosed bracket is literal +case ab in a[b) echo open;; *) echo other;; esac +### expect +other +### end + +### bashbox_interpreter_builtins_exit_ends_only_the_subshell +# exit ends only the subshell +(exit 3); echo $? +### expect +3 +### end + +### bashbox_interpreter_builtins_return_ends_only_the_subshell +# return ends only the subshell +f(){ (return 4); echo $?; }; f +### expect +4 +### end + +### bashbox_interpreter_builtins_errexit_ends_only_the_subshell +# errexit ends only the subshell +(set -e; false; echo no); echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_keeps_earlier_output_in_place +# keeps earlier output in place +echo pre; x=$(echo b); echo "[$x]" +### expect +pre +[b] +### end + +### bashbox_interpreter_builtins_exit_status_reaches +# exit status reaches $? +x=$(exit 4); echo $? +### expect +4 +### end + +### bashbox_interpreter_builtins_state_changes_stay_inside +# state changes stay inside +x=1; y=$(x=2; f(){ :; }; echo $x); echo $x $y; type f >/dev/null 2>&1; echo $? +### expect +1 2 +1 +### end + +### bashbox_interpreter_builtins_does_not_fire_the_exit_trap +# does not fire the EXIT trap +trap 'echo bye' EXIT; x=$(echo a); echo $x +### expect +a +bye +### end + +### bashbox_interpreter_builtins_an_expansion_error_ends_only_the_substitution +# an expansion error ends only the substitution +x=$(echo ${zz:?boom}); echo "after $?" +### expect +after 1 +### end + +### bashbox_interpreter_builtins_fires_after_the_body_of_the_function_that_set_it +# fires after the body of the function that set it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ trap 'echo ret' RETURN; echo in; }; f; echo after +### expect +in +ret +after +### end + +### bashbox_interpreter_builtins_is_not_inherited_by_functions +# is not inherited by functions +trap 'echo ret' RETURN; f(){ echo f; }; f; echo end +### expect +f +end +### end + +### bashbox_interpreter_builtins_stays_set_but_other_functions_do_not_see_it +# stays set but other functions do not see it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ trap 'echo ret' RETURN; }; g(){ echo g; }; f; echo top; g; h(){ g; }; h +### expect +ret +top +g +g +### end + +### bashbox_interpreter_builtins_sees_the_function_locals +# sees the function locals +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ local v=loc; trap 'echo $v' RETURN; }; f +### expect +loc +### end + +### bashbox_interpreter_builtins_input_from_a_missing_file +# input from a missing file +cat < missing.txt; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_creates_the_file +# <> creates the file +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +cat <> rw.txt; echo $?; test -f rw.txt && echo created +### expect +0 +created +### end + +### bashbox_interpreter_builtins_2_1_before_dev_null_keeps_stderr_on_the_pipe +# 2>&1 before >/dev/null keeps stderr on the pipe +{ echo out; echo err >&2; } 2>&1 >/dev/null | cat +### expect +err +### end + +### bashbox_interpreter_builtins_closing_stderr +# closing stderr +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +ls /nope 2>&-; echo y +### expect +y +### end + +### bashbox_interpreter_builtins_file_sends_both_streams_to_the_file +# >&file sends both streams to the file +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +{ echo z; echo e >&2; } >& both.txt; cat both.txt +### expect +z +e +### end + +### bashbox_interpreter_builtins_and_write_and_append_both_streams +# &> and &>> write and append both streams +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +{ echo a; echo b >&2; } &> all.txt; { echo c; } &>> all.txt; cat all.txt +### expect +a +b +c +### end + +### bashbox_interpreter_builtins_dev_stderr_is_fd_2_as_of_that_redirection +# /dev/stderr is fd 2 as of that redirection +echo x > /dev/stderr 2>/dev/null; echo y +### expect +y +### end + +### bashbox_interpreter_builtins_a_missing_directory_fails +# a missing directory fails > +echo a > /no/such/dir/f; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_a_missing_directory_fails_2 +# a missing directory fails >& +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo z >& /no/such/dir/f; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_quoted_heredoc_stays_literal +# quoted heredoc stays literal +cat <<'EOF' +$HOME x +EOF +### expect +$HOME x +### end + +### bashbox_interpreter_builtins_strips_leading_tabs_and_expands +# <<- strips leading tabs and expands +cat <<-EOF + tab $((1+1)) + EOF +### expect +tab 2 +### end + +### bashbox_interpreter_builtins_here_string +# here-string +cat <<< "here $((2+3))" +### expect +here 5 +### end + +### bashbox_interpreter_builtins_an_empty_array +# an empty array +a=(); echo ${#a[@]}; a=(1 2 3); a=(); echo ${#a[@]} +### expect +0 +0 +### end + +### bashbox_interpreter_builtins_to_a_readonly_variable_fails +# to a readonly variable fails +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +readonly r=1 +r=2 +echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_xtrace_of_a_prefix_assignment +# xtrace of a prefix assignment +set -x; X=1 echo hi +### expect +hi +### end + +### bashbox_interpreter_builtins_and_short_circuit +# && and || short-circuit +x=0; echo $((0 && (x=5))) $x $((1 || (x=6))) $x $((1 && 2)) $((0 || 0)) $((0 || 3)) +### expect +0 0 1 0 1 0 1 +### end + +### bashbox_interpreter_builtins_unary_operators +# unary operators +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo $((-(2+3))) $((+4)) $((!0)) $((!5)) $((~0)) $((++5)) $((--5)) +### expect +-5 4 1 0 -1 5 5 +### end + +### bashbox_interpreter_builtins_increment_and_decrement +# increment and decrement +x=5; echo $((x++)) $x $((x--)) $x $((++x)) $((--x)); echo $((y++)) $y +### expect +5 6 6 5 6 5 +0 1 +### end + +### bashbox_interpreter_builtins_ternary +# ternary +echo $((1?2:3)) $((0?2:3)) +### expect +2 3 +### end + +### bashbox_interpreter_builtins_variables_holding_expressions +# variables holding expressions +x=1+2; echo $((x*3)) $(($x*3)) +### expect +9 7 +### end + +### bashbox_interpreter_builtins_positional_parameters +# positional parameters +set -- 5; echo $(($1+1)); (( $1 == 5 )) && echo five +### expect +6 +five +### end + +### bashbox_interpreter_builtins_expansions_and_quotes_inside +# expansions and quotes inside +a=(1 2 3); echo $(("1"+1)) $(( $(echo 2) * 3 )) $(( ${#a[@]} + 1 )) +### expect +2 6 4 +### end + +### bashbox_interpreter_builtins_associative_array_elements +# associative array elements +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -A m; m[foo]=5; echo $((m[foo]*2)); (( m[bar]=3 )); echo ${m[bar]} +### expect +10 +3 +### end + +### bashbox_interpreter_builtins_command_status +# command status +((0)); echo $?; ((2)); echo $? +### expect +1 +0 +### end + +### bashbox_interpreter_builtins_c_style_for_header_with_a_substitution +# c-style for header with a substitution +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +for ((i=$(echo 1); i<3; i++)); do echo $i; done +### expect +1 +2 +### end + +### bashbox_interpreter_builtins_case +# /= +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=5; ((x/=0)); echo $? $x +### expect +1 5 +### end + +### bashbox_interpreter_builtins_case_2 +# %= +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=5; ((x%=0)); echo $? $x +### expect +1 5 +### end + +### bashbox_interpreter_builtins_case_3 +# / +((5/0)); echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_case_4 +# % +((5%0)); echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_string_comparisons +# string comparisons +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +[[ abc != a* ]]; echo $?; [[ abc != x* ]]; echo $?; [[ a < b ]]; echo $?; [[ b > a ]]; echo $?; [[ b < a ]]; echo $? +### expect +1 +0 +0 +0 +1 +### end + +### bashbox_interpreter_builtins_integer_comparisons +# integer comparisons +[[ 3 -eq 3 && 3 -ne 4 && 2 -lt 3 && 3 -le 3 && 4 -gt 3 && 4 -ge 4 ]]; echo $?; [[ 3 -eq 4 ]]; echo $? +### expect +0 +1 +### end + +### bashbox_interpreter_builtins_regex_match +# regex match +[[ abc123 =~ [0-9]+ ]]; echo $?; [[ abc =~ ^b ]]; echo $? +### expect +0 +1 +### end + +### bashbox_interpreter_builtins_regex_fills_bash_rematch +# regex fills BASH_REMATCH +re="(b)(c)"; [[ abc =~ $re ]]; echo $? ${BASH_REMATCH[0]} ${BASH_REMATCH[2]}; [[ a/b =~ a/b ]]; echo $? +### expect +0 bc c +0 +### end + +### bashbox_interpreter_builtins_empty_and_non_empty_strings +# empty and non-empty strings +[[ -z "" && -n x ]]; echo $?; [[ -z x ]]; echo $?; [[ x ]]; echo $?; [[ "" ]]; echo $? +### expect +0 +1 +0 +1 +### end + +### bashbox_interpreter_builtins_variable_is_set +# variable is set +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +v=1; a=(1 2); [[ -v v ]]; echo $?; [[ -v a ]]; echo $?; [[ -v nope ]]; echo $? +### expect +0 +0 +1 +### end + +### bashbox_interpreter_builtins_and_match_newlines +# * and ? match newlines +x=$(printf "a\nb"); [[ $x == a* ]]; echo $?; case $x in a?b) echo nl;; esac +### expect +0 +nl +### end + +### bashbox_interpreter_builtins_unset_in_its_own_function_keeps_a_local_hiding_the_global +# unset in its own function keeps a local hiding the global +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ local x=1; unset x; echo "[${x-unset}]"; x=2; echo $x; }; x=g; f; echo $x +### expect +[unset] +2 +g +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-core.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-core.test.sh new file mode 100644 index 000000000..cfb628064 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-core.test.sh @@ -0,0 +1,1053 @@ +# BashBox interpreter-core cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_interpreter_core_command_substitution_captures_only_its_own_output +# command substitution captures only its own output +echo a; x=$(echo b); echo "[$x]" +### expect +a +[b] +### end + +### bashbox_interpreter_core_exit_inside_command_substitution_ends_only_the_subshell +# exit inside command substitution ends only the subshell +x=$(echo in; exit 3); echo "$? [$x]" +### expect +3 [in] +### end + +### bashbox_interpreter_core_assignment_only_command_reports_the_substitution_status +# assignment-only command reports the substitution status +x=$(false); echo $? +### expect +1 +### end + +### bashbox_interpreter_core_exit_trap_waits_for_the_whole_script +# EXIT trap waits for the whole script +trap "echo bye" EXIT; eval "echo hi"; x=$(echo in); echo "after $x" +### expect +hi +after in +bye +### end + +### bashbox_interpreter_core_empty_exit_trap_is_ignored +# empty EXIT trap is ignored +trap "" EXIT; echo x +### expect +x +### end + +### bashbox_interpreter_core_skipped_still_reaches +# skipped && still reaches || +false && echo a || echo b +### expect +b +### end + +### bashbox_interpreter_core_skips_on_success +# || skips on success +true || echo no; echo $? +### expect +0 +### end + +### bashbox_interpreter_core_err_trap_fires_for_a_failing_command +# ERR trap fires for a failing command +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap "echo caught \$?" ERR; false; echo next $? +### expect +caught 1 +next 1 +### end + +### bashbox_interpreter_core_err_trap_skips_non_final_members_of_an_and_or_list +# ERR trap skips non-final members of an and-or list +trap "echo caught" ERR; false && true; false || true; echo done +### expect +done +### end + +### bashbox_interpreter_core_set_e_ignores_a_failure_before +# set -e ignores a failure before && +set -e; false && true; echo yes +### expect +yes +### end + +### bashbox_interpreter_core_set_e_ignores_a_negated_pipeline +# set -e ignores a negated pipeline +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -e; ! true; echo yes +### expect +yes +### end + +### bashbox_interpreter_core_set_o_errexit_then_set_e +# set -o errexit then set +e +set -o errexit; set +e; false; echo yes +### expect +yes +### end + +### bashbox_interpreter_core_pipeline_status_is_the_last_command +# pipeline status is the last command +false | true; echo $? +### expect +0 +### end + +### bashbox_interpreter_core_negated_pipeline +# negated pipeline +! false; echo $?; ! true; echo $? +### expect +0 +1 +### end + +### bashbox_interpreter_core_pipefail_reports_the_rightmost_failure +# pipefail reports the rightmost failure +set -o pipefail; false | true; echo $?; set +o pipefail; false | true; echo $? +### expect +1 +0 +### end + +### bashbox_interpreter_core_pipes_stderr_too +# |& pipes stderr too +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +{ echo out; echo err >&2; } |& cat +### expect +out +err +### end + +### bashbox_interpreter_core_stderr_of_a_piped_command_is_not_piped +# stderr of a piped command is not piped +{ echo out; echo err >&2; } | cat +### expect +out +### end + +### bashbox_interpreter_core_compound_command_output_is_redirected +# compound command output is redirected +{ echo a; echo b >&2; } > out 2>&1; cat out +### expect +a +b +### end + +### bashbox_interpreter_core_loop_reads_from_a_redirected_file +# loop reads from a redirected file +printf "1\n2\n" > in; while read l; do echo $l; done < in +### expect +1 +2 +### end + +### bashbox_interpreter_core_failed_compound_redirection_skips_the_command +# failed compound redirection skips the command +while read l; do echo "<$l>"; done < nofile; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_every_compound_command_kind_runs +# every compound command kind runs +if true; then echo t; fi | cat; case a in a) echo c;; esac; (echo sub); ((1)) && [[ a ]] && echo arith; until true; do :; done; for ((i=0; i<1; i++)); do echo $i; done +### expect +t +c +sub +arith +0 +### end + +### bashbox_interpreter_core_break_and_continue_with_levels +# break and continue with levels +for i in 1 2 3; do for j in a b; do test $j = b && continue 2; test $i = 3 && break 2; echo $i$j; done; done; until false; do break; done; echo end +### expect +1a +2a +end +### end + +### bashbox_interpreter_core_break_and_continue_outside_a_loop +# break and continue outside a loop +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +break; continue 2; echo $? +### expect +0 +### end + +### bashbox_interpreter_core_break_inside_a_function_cannot_leave_the_caller_loop +# break inside a function cannot leave the caller loop +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() { break; }; for i in 1; do f; echo no; done +### expect +no +### end + +### bashbox_interpreter_core_command_not_found +# command not found +nope; echo $? +### expect +127 +### end + +### bashbox_interpreter_core_command_name_is_field_split +# command name is field split +CMD="echo a b"; $CMD c +### expect +a b c +### end + +### bashbox_interpreter_core_empty_expansion_leaves_only_the_assignment +# empty expansion leaves only the assignment +e=; x=1 $e; echo $x +### expect +1 +### end + +### bashbox_interpreter_core_prefix_assignment_reaches_a_command_env_only +# prefix assignment reaches a command env only +FOO=bar printenv FOO; echo "[$FOO]" +### expect +bar +[] +### end + +### bashbox_interpreter_core_prefix_assignment_is_temporary_for_functions +# prefix assignment is temporary for functions +f() { echo "x=$x"; }; x=1 f; echo "[$x]" +### expect +x=1 +[] +### end + +### bashbox_interpreter_core_prefix_assignment_is_temporary_for_builtins +# prefix assignment is temporary for builtins +y=0; y=5 eval "echo \$y"; echo $y +### expect +5 +0 +### end + +### bashbox_interpreter_core_repeated_prefix_assignment_restores_the_original +# repeated prefix assignment restores the original +x=0; x=1 x=2 printenv x; echo $x +### expect +2 +0 +### end + +### bashbox_interpreter_core_readonly_prefix_assignment_is_reported_the_command_still_run +# readonly prefix assignment is reported, the command still runs +readonly R=1; R=2 echo "[$R]"; echo "s=$?" +### expect +[1] +s=0 +### end + +### bashbox_interpreter_core_xtrace_prints_assignments_and_commands +# xtrace prints assignments and commands +set -x; x=1 printenv x; y=2 +### expect +1 +### end + +### bashbox_interpreter_core_bad_substitution_fails_only_its_command +# bad substitution fails only its command +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo ${x!} +echo after $? +### expect +after 1 +### end + +### bashbox_interpreter_core_expansion_error_abandons_the_rest_of_its_line +# expansion error abandons the rest of its line +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() { echo ${x!}; echo in; } +f; echo same +echo next +### expect +next +### end + +### bashbox_interpreter_core_cannot_assign_fails_only_its_command +# cannot assign fails only its command +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo ${1:=x} +echo after $? +### expect +after 1 +### end + +### bashbox_interpreter_core_return_1_wraps_to_255 +# return -1 wraps to 255 +f() { return -1; }; f; echo $? +### expect +255 +### end + +### bashbox_interpreter_core_return_outside_a_function +# return outside a function +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +return; echo $? +### expect +2 +### end + +### bashbox_interpreter_core_export_tracks_later_assignments +# export tracks later assignments +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +export X=1; X=2; printenv X; export Y; Y=3; printenv Y; export -n Z=4; echo $Z +### expect +2 +3 +4 +### end + +### bashbox_interpreter_core_export_of_a_readonly_variable +# export of a readonly variable +readonly R=1; export R=2; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_export_lists_variables +# export lists variables +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +export B=2 A=1; export | grep "[AB]=" +### expect +declare -x A="1" +declare -x B="2" +### end + +### bashbox_interpreter_core_unset_f_and_v +# unset -f and -v +f() { echo fn; }; unset -f f; f; a=1; unset -v a; echo "[$a]" +### expect +[] +### end + +### bashbox_interpreter_core_unset_an_array_element +# unset an array element +a=(1 2 3); unset "a[1]"; echo ${a[@]} +### expect +1 3 +### end + +### bashbox_interpreter_core_unset_a_whole_array +# unset a whole array +a=(1 2); unset a; echo "[${a[@]}]" +### expect +[] +### end + +### bashbox_interpreter_core_unset_a_readonly_variable +# unset a readonly variable +readonly r=1; unset r; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_local_outside_a_function +# local outside a function +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +local x=1; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_local_variables_vanish_after_the_function +# local variables vanish after the function +f() { local a=1 b; b=2; echo $a$b; }; f; echo "[$a$b]" +### expect +12 +[] +### end + +### bashbox_interpreter_core_local_of_a_readonly_variable +# local of a readonly variable +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +readonly r=1; f() { local r=2; }; f; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_set_lists_variables_quoting_when_needed +# set lists variables, quoting when needed +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=1; b="x y"; c=""; d="it's"; set | grep "^[abcd]=" +### expect +a=1 +b='x y' +c= +d='it'\''s' +### end + +### bashbox_interpreter_core_set_positional_parameters +# set positional parameters +set -- x y; echo $# $1; set a b; echo $2 +### expect +2 x +b +### end + +### bashbox_interpreter_core_set_c_and_c +# set -C and +C +set -C; echo a > f; echo b > f; echo $?; set +C; echo c > f; cat f +### expect +1 +c +### end + +### bashbox_interpreter_core_set_f_disables_globbing +# set -f disables globbing +touch a.txt; set -f; echo *.txt; set +f; echo *.txt +### expect +*.txt +a.txt +### end + +### bashbox_interpreter_core_set_v_has_no_effect_on_a_one_line_script +# set -v has no effect on a one-line script +set -v; echo hi +### expect +hi +### end + +### bashbox_interpreter_core_set_u_and_u +# set -u and +u +set -u; set +u; echo "[$nope]" +### expect +[] +### end + +### bashbox_interpreter_core_set_x_stops_tracing +# set +x stops tracing +set -x; set +x; echo hi +### expect +hi +### end + +### bashbox_interpreter_core_shopt_succeeds +# shopt succeeds +shopt -s nullglob; echo $? +### expect +0 +### end + +### bashbox_interpreter_core_source_without_a_file +# source without a file +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +source; echo $? +### expect +2 +### end + +### bashbox_interpreter_core_source_a_missing_file +# source a missing file +source nope.sh; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_source_arguments_become_positional_parameters +# source arguments become positional parameters +echo "echo sourced \$1 \$#" > s.sh; set -- p q; source s.sh arg; . ./s.sh; echo $1 +### expect +sourced arg 1 +sourced p 2 +p +### end + +### bashbox_interpreter_core_source_without_arguments_can_set_positional_parameters +# source without arguments can set positional parameters +echo "set -- z" > s.sh; source s.sh; echo $1 +### expect +z +### end + +### bashbox_interpreter_core_return_ends_a_sourced_file +# return ends a sourced file +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo "return 4; echo no" > s.sh; source s.sh; echo $? +### expect +4 +### end + +### bashbox_interpreter_core_return_in_a_file_sourced_by_a_function +# return in a file sourced by a function +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo "x=1; return 5; x=2" > s; f() { source ./s; echo "s=$? x=$x"; }; f +### expect +s=5 x=1 +### end + +### bashbox_interpreter_core_eval_runs_in_the_current_shell +# eval runs in the current shell +eval "a=1;" "echo \$a"; eval; echo $? +### expect +1 +0 +### end + +### bashbox_interpreter_core_eval_output_follows_its_redirection +# eval output follows its redirection +echo a; eval "echo b" > f; cat f +### expect +a +b +### end + +### bashbox_interpreter_core_declare_without_a_value_keeps_the_variable +# declare without a value keeps the variable +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=1; declare x; echo $x; declare -p x nope; echo $? +### expect +1 +declare -- x="1" +1 +### end + +### bashbox_interpreter_core_declare_p_readonly_and_arrays +# declare -p readonly and arrays +declare -r y=2; declare -p y; a=(1 "b c"); declare -p a +### expect +declare -r y="2" +declare -a a=([0]="1" [1]="b c") +### end + +### bashbox_interpreter_core_declare_in_a_function_is_local_unless_g +# declare in a function is local unless -g +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() { declare x=1; declare -g g=2; }; f; echo "[$x][$g]" +### expect +[][2] +### end + +### bashbox_interpreter_core_declare_a +# declare -a +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -a arr; arr[0]=x; echo ${arr[0]}; declare -a b=v; echo ${b[0]} +### expect +x +v +### end + +### bashbox_interpreter_core_declare_of_a_readonly_variable +# declare of a readonly variable +readonly RO=1; declare RO=2; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_declare_x +# declare -x +declare -x E=1; printenv E +### expect +1 +### end + +### bashbox_interpreter_core_declare_alone_lists_variables +# declare alone lists variables +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +q=1; declare | grep "^q=" +### expect +q=1 +### end + +### bashbox_interpreter_core_let_without_arguments +# let without arguments +let; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_let_status_follows_the_last_value +# let status follows the last value +let x=0; echo $?; let y=2 z=3; echo $y$z $? +### expect +1 +23 0 +### end + +### bashbox_interpreter_core_shift +# shift +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- a b c; shift; echo $*; shift 2; echo $# $?; shift; echo $? +### expect +b c +0 0 +1 +### end + +### bashbox_interpreter_core_getopts_walks_grouped_options_and_arguments +# getopts walks grouped options and arguments +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- -ac -bval -- -z rest1; while getopts "ab:c" o; do echo "$o ${OPTARG-unset} $OPTIND"; done; shift $((OPTIND-1)); echo "rest: $*" +### expect +a unset 1 +c unset 2 +b val 3 +rest: -z rest1 +### end + +### bashbox_interpreter_core_getopts_silent_mode +# getopts silent mode +set -- -z -b; while getopts ":ab:" o; do echo "$o ${OPTARG-unset}"; done +### expect +? z +: b +### end + +### bashbox_interpreter_core_getopts_reports_errors +# getopts reports errors +set -- -z -b; while getopts "ab:" o; do echo "$o ${OPTARG-unset}"; done +### expect +? unset +? unset +### end + +### bashbox_interpreter_core_getopts_with_explicit_arguments +# getopts with explicit arguments +getopts a o -a -b; echo "$o $?"; getopts a o -a -b; echo "$o $? ${OPTARG-u}"; getopts a o -a -b; echo "$o $?" +### expect +a 0 +? 0 u +? 1 +### end + +### bashbox_interpreter_core_getopts_restarts_when_optind_is_reset +# getopts restarts when OPTIND is reset +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +getopts ab o -ab; echo $o; OPTIND=1; getopts xy o -x; echo $o +### expect +a +x +### end + +### bashbox_interpreter_core_getopts_stops_at_a_non_option +# getopts stops at a non-option +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- a -b; getopts b o; echo "$? $o $OPTIND" +### expect +1 ? 1 +### end + +### bashbox_interpreter_core_getopts_option_argument_in_the_next_word +# getopts option argument in the next word +getopts "a:" o -a val; echo "$o $OPTARG $OPTIND" +### expect +a val 3 +### end + +### bashbox_interpreter_core_getopts_usage +# getopts usage +getopts; echo $? +### expect +2 +### end + +### bashbox_interpreter_core_type_t +# type -t +f(){ :; }; type -t f cd ls echo nope; echo $? +### expect +function +builtin +file +builtin +1 +### end + +### bashbox_interpreter_core_type_describes_each_name +# type describes each name +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +type nope cd ls; echo $? +### expect +cd is a shell builtin +ls is /usr/bin/ls +1 +### end + +### bashbox_interpreter_core_command_v +# command -v +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ :; }; command -v cd ls f nope; echo $?; command -v nope; echo $? +### expect +cd +/usr/bin/ls +f +0 +1 +### end + +### bashbox_interpreter_core_command_skips_functions +# command skips functions +f(){ :; }; command f; echo $?; command; echo $?; echo() { :; }; command echo hi +### expect +127 +0 +hi +### end + +### bashbox_interpreter_core_command_runs_builtins +# command runs builtins +command cd /tmp; pwd +### expect +/tmp +### end + +### bashbox_interpreter_core_builtin +# builtin +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +builtin cd /tmp && pwd; builtin echo hi; builtin cat; echo $?; builtin; echo $? +### expect +/tmp +hi +1 +0 +### end + +### bashbox_interpreter_core_exec_runs_a_command_and_ends_the_script +# exec runs a command and ends the script +exec echo done; echo no +### expect +done +### end + +### bashbox_interpreter_core_exec_without_a_command +# exec without a command +exec; echo still +### expect +still +### end + +### bashbox_interpreter_core_alias_define_list_and_query +# alias define, list and query +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +alias a=b c="it's"; alias; alias a; alias zz; echo $? +### expect +alias a='b' +alias c='it'\''s' +alias a='b' +1 +### end + +### bashbox_interpreter_core_unalias +# unalias +alias a=b; unalias a zz; echo $?; alias x=y; unalias -a; alias +### expect +1 +### end + +### bashbox_interpreter_core_trap_listing_order_and_quoting +# trap listing order and quoting +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap "echo b" ERR; trap "echo a" EXIT; trap "" INT; trap "it's" SIGTERM; trap +### expect +trap -- 'echo a' EXIT +trap -- '' SIGINT +trap -- 'it'\''s' SIGTERM +trap -- 'echo b' ERR +a +### end + +### bashbox_interpreter_core_trap_with_a_lone_signal_resets_it +# trap with a lone signal resets it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap "echo x" 0; trap; trap EXIT; trap; echo done +### expect +trap -- 'echo x' EXIT +done +### end + +### bashbox_interpreter_core_err_trap_keeps +# ERR trap keeps $? +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap "true" ERR; false; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_pushd_and_popd +# pushd and popd +cd /tmp; mkdir -p /tmp/a /tmp/b; pushd /tmp/a; pushd /tmp/b; pushd; popd; popd; popd; echo $? +### expect +/tmp/a /tmp +/tmp/b /tmp/a /tmp +/tmp/a /tmp/b /tmp +/tmp/b /tmp +/tmp +1 +### end + +### bashbox_interpreter_core_pushd_without_a_stack +# pushd without a stack +pushd; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_pushd_to_a_missing_directory +# pushd to a missing directory +pushd /nope; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_pushd_abbreviates_home +# pushd abbreviates HOME +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +cd /tmp; pushd ~ +### expect +~ /tmp +### end + +### bashbox_interpreter_core_hash +# hash +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +hash; hash -r; : a b; echo $? +### expect +hash: hash table empty +0 +### end + +### bashbox_interpreter_core_reply_is_not_trimmed +# REPLY is not trimmed +echo " a b " | { read; echo "[$REPLY]"; } +### expect +[ a b ] +### end + +### bashbox_interpreter_core_non_whitespace_ifs_keeps_the_rest_intact +# non-whitespace IFS keeps the rest intact +echo "a:b:c:d" | { IFS=: read x y; echo "[$x][$y]"; } +### expect +[a][b:c:d] +### end + +### bashbox_interpreter_core_adjacent_separators_make_empty_fields +# adjacent separators make empty fields +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo "a::b" | { IFS=: read -a x; echo "${#x[@]} [${x[1]}]"; } +### expect +3 [] +### end + +### bashbox_interpreter_core_unterminated_line_assigns_but_fails +# unterminated line assigns but fails +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf "a b" | { read x; echo "$? [$x]"; } +### expect +1 [a b] +### end + +### bashbox_interpreter_core_escaped_separator_stays_in_the_field +# escaped separator stays in the field +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo "a\\ b c" | { read x y; echo "[$x][$y]"; } +### expect +[a b][c] +### end + +### bashbox_interpreter_core_empty_ifs_disables_splitting +# empty IFS disables splitting +printf "x y" | { IFS= read -r v; echo "[$v]"; } +### expect +[x y] +### end + +### bashbox_interpreter_core_nul_delimited_records +# NUL delimited records +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf "a\0b\0" | while read -r -d "" v; do echo "[$v]"; done +### expect +[a] +[b] +### end + +### bashbox_interpreter_core_mapfile_on_nul_delimiters +# mapfile on NUL delimiters +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf "a\0b\0" | { mapfile -d "" arr; echo ${#arr[@]}; } +### expect +2 +### end + +### bashbox_interpreter_core_readarray_t_into_mapfile +# readarray -t into MAPFILE +printf "a\nb" | { readarray -t; echo "${MAPFILE[1]}"; } +### expect +b +### end + +### bashbox_interpreter_core_read_from_a_pipe_into_eval +# read from a pipe into eval +echo hi | eval "read x; echo \$x" +### expect +hi +### end + +### bashbox_interpreter_core_default +# default +time echo hi +### expect +hi +### end + +### bashbox_interpreter_core_posix +# posix +time -p true +### expect +### end + +### bashbox_interpreter_core_an_invalid_regex_fails_with_status_2 +# an invalid regex fails [[ with status 2 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +re='('; [[ a =~ $re ]]; echo $?; re='[[:foo:]]'; [[ a =~ $re ]]; echo $?; [[ abc =~ b(c) ]]; echo $? ${BASH_REMATCH[@]} +### expect +2 +2 +0 bc c +### end + +### bashbox_interpreter_core_evaluates_integer_operands_without_expanding_them_again +# [[ ]] evaluates integer operands without expanding them again +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x='$(echo hi >&2; echo 1)'; [[ $x -eq 1 ]]; echo $?; x='1+1'; [[ $x -eq 2 ]]; echo $?; [[ 08 -eq 1 ]]; echo $? +### expect +1 +0 +1 +### end + +### bashbox_interpreter_core_assignment_subscripts_are_expanded_and_arithmetic_for_indexe +# assignment subscripts are expanded, and arithmetic for indexed arrays +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +i=3; a[$i]=x; a[i+1]=y; a[-1]=z; b=([i+1]=p [6]=q); read 'c[1+1]' <<< r; declare -p a b c +### expect +declare -a a=([3]="x" [4]="z") +declare -a b=([4]="p" [6]="q") +declare -a c=([2]="r") +### end + +### bashbox_interpreter_core_an_associative_array_s_subscript_is_its_key +# an associative array's subscript is its key +declare -A m; k='a b'; m[$k]=1; m[1+1]=2; echo "${m[a b]} ${m[1+1]}" +### expect +1 2 +### end + +### bashbox_interpreter_core_a_negative_subscript_counts_back_from_the_end +# a negative subscript counts back from the end +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=(1 2 3); a[-1]+=x; declare -p a; a[-5]=q; echo same +echo next $? +### expect +declare -a a=([0]="1" [1]="2" [2]="3x") +next 1 +### end + +### bashbox_interpreter_core_unset_takes_options_first +# unset takes options first +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +unset -v x -n y; echo $?; unset -z; echo $?; unset -fn; echo $?; unset -n; echo $?; unset 'a b'; echo $? +### expect +1 +2 +0 +0 +0 +### end + +### bashbox_interpreter_core_unset_of_an_element_and_of_a_readonly_variable +# unset of an element and of a readonly variable +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=(1 2 3); unset 'a[1]'; declare -p a; readonly r; unset -n r; echo $?; readonly -a ra=(1); unset 'ra[0]'; echo $? +### expect +declare -a a=([0]="1" [2]="3") +1 +1 +### end + +### bashbox_interpreter_core_set_n_reads_the_rest_without_running_it +# set -n reads the rest without running it +echo a; set -n; echo b +### expect +a +### end + +### bashbox_interpreter_core_set_o_noexec_too +# set -o noexec too +set -o noexec; echo hi +### expect +### end + +### bashbox_interpreter_core_set_k_takes_assignments_from_anywhere_in_a_command +# set -k takes assignments from anywhere in a command +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() { echo "$x"; }; set -k; echo a=b c; f x=5; echo $-; set +k; echo a=b +### expect +c +5 +hkBc +a=b +### end + +### bashbox_interpreter_core_a_subscript_may_quote_or_escape_a +# a subscript may quote or escape a ] +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -A a b c; a["x]"]=1; b[\]]=2; c['y z']=3; declare -p a b c +### expect +declare -A a=(["x]"]="1" ) +declare -A b=(["]"]="2" ) +declare -A c=(["y z"]="3" ) +### end + +### bashbox_interpreter_core_unset_stops_taking_options_at +# unset stops taking options at -- +x=1; unset -- x; echo $? ${x-unset} +### expect +0 unset +### end + +### bashbox_interpreter_core_redirections_report_why_a_file_can_t_be_opened +# redirections report why a file can't be opened +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +true <> /tmp; echo $?; touch f; echo hi > f/x; echo $?; cat < f/x; echo $?; ln -s loop loop; echo hi > loop; echo $? +### expect +1 +1 +1 +1 +### end + +### bashbox_interpreter_core_reading_a_directory_fails +# reading a directory fails +cat < /tmp; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_source_names_a_directory_it_can_t_read +# source names a directory it can't read +source /tmp; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_set_o_keyword_shows_as_k +# set -o keyword shows as k +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -o keyword; echo $-; set -o | grep -E '^(keyword|noexec) ' +### expect +hkBc +keyword on +noexec off +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh new file mode 100644 index 000000000..b892ee413 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh @@ -0,0 +1,403 @@ +# BashBox just-bash-compat cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_just_bash_compat_read_consumes_stdin_line_by_line +# read consumes stdin line by line +printf "a\nb\n" | while read l; do echo "<$l>"; done +### expect + + +### end + +### bashbox_just_bash_compat_loop_reads_redirected_file +# loop reads redirected file +printf "1\n2\n" > in; while read l; do echo $l | cat; done < in +### expect +1 +2 +### end + +### bashbox_just_bash_compat_nested_reads_share_stdin +# nested reads share stdin +printf "1\n2\n" | { { read a; }; read b; echo $a$b; } +### expect +12 +### end + +### bashbox_just_bash_compat_read_returns_1_on_unterminated_last_line +# read returns 1 on unterminated last line +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf "x" | { read v; echo "$?:$v"; } +### expect +1:x +### end + +### bashbox_just_bash_compat_pipe_into_if +# pipe into if +echo hi | if true; then cat; fi +### expect +hi +### end + +### bashbox_just_bash_compat_loop_output_goes_through_the_pipe +# loop output goes through the pipe +for i in b a; do echo $i; done | sort +### expect +a +b +### end + +### bashbox_just_bash_compat_loop_redirected_to_dev_null +# loop redirected to /dev/null +while true; do echo x; break; done >/dev/null; echo ok +### expect +ok +### end + +### bashbox_just_bash_compat_group_redirected_to_file_with_2_1 +# group redirected to file with 2>&1 +{ echo o; echo e >&2; } > f 2>&1; cat f +### expect +o +e +### end + +### bashbox_just_bash_compat_stderr_to_dev_null +# stderr to /dev/null +cat nope 2>/dev/null; echo rc=$? +### expect +rc=1 +### end + +### bashbox_just_bash_compat_stderr_into_the_pipe +# stderr into the pipe +ls nope 2>&1 | grep -c nope +### expect +1 +### end + +### bashbox_just_bash_compat_stdout_to_stderr +# stdout to stderr +echo e >&2 2>/dev/null; echo done +### expect +done +### end + +### bashbox_just_bash_compat_missing_input_file_skips_the_command +# missing input file skips the command +cat < nofile 2>/dev/null; echo rc=$? +### expect +rc=1 +### end + +### bashbox_just_bash_compat_break_exits_loop_with_status_0 +# break exits loop with status 0 +while :; do false; break; done; echo $? +### expect +0 +### end + +### bashbox_just_bash_compat_bare_assignment_status +# bare assignment status +false; x=1; echo $? +### expect +0 +### end + +### bashbox_just_bash_compat_assignment_takes_substitution_status +# assignment takes substitution status +x=$(false); echo $? +### expect +1 +### end + +### bashbox_just_bash_compat_quoted_keeps_words +# quoted $@ keeps words +set -- "a b" c; printf "[%s]" "$@"; echo +### expect +[a b][c] +### end + +### bashbox_just_bash_compat_empty_quoted_vanishes +# empty quoted $@ vanishes +set --; printf "[%s]" "$@" x; echo +### expect +[x] +### end + +### bashbox_just_bash_compat_joins_with_surrounding_text +# $@ joins with surrounding text +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- a b; printf "[%s]" "x$@y"; echo +### expect +[xa][by] +### end + +### bashbox_just_bash_compat_quoted_array_expansion +# quoted array expansion +a=("x y" z); printf "[%s]" "${a[@]}" "${a[*]}"; echo +### expect +[x y][z][x y z] +### end + +### bashbox_just_bash_compat_1_idiom +# ${1+"$@"} idiom +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- "a b" c; for w in "${1+"$@"}"; do echo "[$w]"; done +### expect +[a b] +[c] +### end + +### bashbox_just_bash_compat_positional_slice +# positional slice +set -- a b c; echo "${@:2}" +### expect +b c +### end + +### bashbox_just_bash_compat_positional_beyond_9 +# positional beyond 9 +set -- 1 2 3 4 5 6 7 8 9 10 11; echo ${10} ${11} +### expect +10 11 +### end + +### bashbox_just_bash_compat_unquoted_variable_is_split +# unquoted variable is split +x="a b"; printf "[%s]" $x "$x"; echo +### expect +[a][b][a b] +### end + +### bashbox_just_bash_compat_unquoted_empty_variable_vanishes +# unquoted empty variable vanishes +e=; printf "[%s]" $e "$e" x; echo +### expect +[][x] +### end + +### bashbox_just_bash_compat_custom_ifs_splits_expansions_only +# custom IFS splits expansions only +IFS=:; x=a:b; printf "[%s]" $x; echo +### expect +[a][b] +### end + +### bashbox_just_bash_compat_command_word_is_split +# command word is split +C="echo hi"; $C there +### expect +hi there +### end + +### bashbox_just_bash_compat_default_and_alternative_operators +# default and alternative operators +unset u; e=; echo "[${u-d}][${u+a}][${e-d}][${e+a}][${e:-d}]" +### expect +[d][][][a][d] +### end + +### bashbox_just_bash_compat_cannot_assign_to_positional +# cannot assign to positional +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +: ${1:=x}; echo after +### expect +### end + +### bashbox_just_bash_compat_error_if_unset_stops_the_script +# error-if-unset stops the script +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a; echo ${zz:?boom}; echo after +### expect +a +### end + +### bashbox_just_bash_compat_glob_matches_directories_only_with +# glob matches directories only with */ +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +mkdir d1 d2; touch f1; for d in */; do echo $d; done +### expect +d1/ +d2/ +### end + +### bashbox_just_bash_compat_quoted_glob_stays_literal +# quoted glob stays literal +touch a.txt b.txt; echo *.txt "*.txt" \*.txt +### expect +a.txt b.txt *.txt *.txt +### end + +### bashbox_just_bash_compat_glob_skips_dotfiles +# glob skips dotfiles +touch .h v; echo * +### expect +v +### end + +### bashbox_just_bash_compat_glob_bracket_expression +# glob bracket expression +touch a1 a2 b1; echo a[12] [!a]1 +### expect +a1 a2 b1 +### end + +### bashbox_just_bash_compat_glob_in_a_directory_component +# glob in a directory component +mkdir -p p/x p/y; touch p/x/f p/y/f; echo p/*/f +### expect +p/x/f p/y/f +### end + +### bashbox_just_bash_compat_sed_append_keeps_leading_blanks +# sed append keeps leading blanks +echo x | sed "a\ indented" +### expect +x + indented +### end + +### bashbox_just_bash_compat_sed_addresses_and_commands +# sed addresses and commands +printf "1\n2\n3\n" | sed -n 2p; printf "1\n2\n3\n" | sed 2d; printf "a\nb\n" | sed "1c X" +### expect +2 +1 +3 +X +b +### end + +### bashbox_just_bash_compat_sed_range_and_negation +# sed range and negation +printf "x\nstart\ny\nend\nz\n" | sed -n "/start/,/end/p"; printf "1\n2\n3\n" | sed "\$!d" +### expect +start +y +end +3 +### end + +### bashbox_just_bash_compat_sed_multiple_commands +# sed multiple commands +echo abc | sed "s/a/A/;s/c/C/" +### expect +AbC +### end + +### bashbox_just_bash_compat_sed_bre_groups_and_escaped_backslash +# sed BRE groups and escaped backslash +echo ab | sed "s/\(a\)\(b\)/\2\1/"; echo ab | sed "s/\(a\)/\\\\\1/" +### expect +ba +\ab +### end + +### bashbox_just_bash_compat_sed_nth_occurrence +# sed nth occurrence +echo aaa | sed s/a/X/2; echo aaa | sed s/a/X/2g +### expect +aXa +aXX +### end + +### bashbox_just_bash_compat_sed_n_and_hold_space +# sed N and hold space +printf "1\n2\n3\n4\n" | sed "N;s/\n/,/"; printf "1\n2\n" | sed -n "h;n;G;p" +### expect +1,2 +3,4 +2 +1 +### end + +### bashbox_just_bash_compat_head_and_tail_n_shorthand +# head and tail -N shorthand +printf "1\n2\n3\n" | head -2; printf "1\n2\n3\n" | tail -2 +### expect +1 +2 +2 +3 +### end + +### bashbox_just_bash_compat_tr_octal_escapes +# tr octal escapes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a | tr a "\101" +### expect +A +### end + +### bashbox_just_bash_compat_cp_f_and_n +# cp -f and -n +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a > f; echo b > g; cp -f f h && cat h; cp -n g f; cat f +### expect +a +a +### end + +### bashbox_just_bash_compat_yes_is_finite_in_a_pipe +# yes is finite in a pipe +yes | head -2; yes ab | head -1 +### expect +y +y +ab +### end + +### bashbox_just_bash_compat_realpath +# realpath +mkdir -p r/s; cd r; realpath s . | sed "s|.*/||" +### expect +s +r +### end + +### bashbox_just_bash_compat_mktemp_creates_a_private_file +# mktemp creates a private file +f=$(mktemp); test -f "$f" && echo file; d=$(mktemp -d); test -d "$d" && echo dir +### expect +file +dir +### end + +### bashbox_just_bash_compat_declare_a_compound_assignment_keeps_quoted_values +# declare -A compound assignment keeps quoted values +declare -A m=([k]="hello world" [j]=x); echo "${m[k]}|${m[j]}|${#m[@]}" +### expect +hello world|x|2 +### end + +### bashbox_just_bash_compat_local_a_compound_assignment +# local -A compound assignment +f(){ local -A m=([a]=1 [b]="2 3"); echo "${m[a]}${m[b]}"; }; f +### expect +12 3 +### end + +### bashbox_just_bash_compat_keyed_elements_in_an_indexed_array +# keyed elements in an indexed array +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=(x [5]=y z); echo ${!a[@]} ${a[6]} +### expect +0 5 6 z +### end + +### bashbox_just_bash_compat_array_keys_are_expanded +# array keys are expanded +k=key; declare -A m=([$k]="v w"); echo "${m[key]}" +### expect +v w +### end + +### bashbox_just_bash_compat_readonly_array_declaration +# readonly array declaration +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +readonly -a r=(1 2); echo ${r[1]} +### expect +2 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh new file mode 100644 index 000000000..35f532161 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh @@ -0,0 +1,260 @@ +# BashBox lexer cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_lexer_comments +# comments +echo a # not printed +# whole line +echo b +### expect +a +b +### end + +### bashbox_lexer_trailing_whitespace +# trailing whitespace +echo a +### expect +a +### end + +### bashbox_lexer_line_continuation_between_words +# line continuation between words +echo a \ + b +### expect +a b +### end + +### bashbox_lexer_line_continuation_inside_a_word +# line continuation inside a word +echo a\ +b +### expect +ab +### end + +### bashbox_lexer_trailing_backslash +# trailing backslash +echo a\ +### expect +a\ +### end + +### bashbox_lexer_newlines_inside_quotes +# newlines inside quotes +echo 'a +b' "c +d" +### expect +a +b c +d +### end + +### bashbox_lexer_escapes_inside_backticks +# escapes inside backticks +echo `echo \`echo hi\`` +### expect +hi +### end + +### bashbox_lexer_parens_inside_quoted_command_substitution +# parens inside quoted command substitution +echo $(echo ")") $(echo "a\"b") $(echo 'x)') +### expect +) a"b x) +### end + +### bashbox_lexer_lone_dollar_signs +# lone dollar signs +echo $% a$ $ +### expect +$% a$ $ +### end + +### bashbox_lexer_nested_braces_in_parameter_expansion +# nested braces in parameter expansion +echo ${x:-{a}} +### expect +{a} +### end + +### bashbox_lexer_empty_braces_are_a_word +# empty braces are a word +echo {} +### expect +{} +### end + +### bashbox_lexer_bang_followed_by_text_is_a_word +# bang followed by text is a word +echo !x +### expect +!x +### end + +### bashbox_lexer_double_brackets_followed_by_text_are_a_word +# double brackets followed by text are a word +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo ]]x +### expect +]]x +### end + +### bashbox_lexer_nested_subshell_closing_with +# nested subshell closing with )) +( (echo hi)) +### expect +hi +### end + +### bashbox_lexer_nested_parens_in_arithmetic_command +# nested parens in arithmetic command +(( ((1+2)) == 3 && (1+(2)) == 3 )) && echo yes +### expect +yes +### end + +### bashbox_lexer_is_a_shift_inside +# << is a shift inside (( )) +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +(( x = 1 << 2 )) +echo $x +### expect +4 +### end + +### bashbox_lexer_inside_c_style_for_header +# ;; inside C-style for header +for ((;;)); do echo x; break; done +### expect +x +### end + +### bashbox_lexer_parens_inside_in_double_quotes +# parens inside $(...) in double quotes +echo "a $(echo ")") b" +### expect +a ) b +### end + +### bashbox_lexer_backticks_in_double_quotes +# backticks in double quotes +echo "a `echo ")"` b" +### expect +a ) b +### end + +### bashbox_lexer_quoted_brace_in +# quoted brace in ${...} +echo ${x:-'}'} +### expect +} +### end + +### bashbox_lexer_escaped_quote_in_ansi_c_quoting +# escaped quote in ANSI-C quoting +echo $'a\'b' +### expect +a'b +### end + +### bashbox_lexer_nested_parens_in +# nested parens in $((...)) +echo $((1+(2))) foo +### expect +3 foo +### end + +### bashbox_lexer_single_quoted +# single quoted +x=1; cat << 'EOF' +$x +EOF +### expect +$x +### end + +### bashbox_lexer_double_quoted +# double quoted +x=1; cat <<"EOF" +$x +EOF +### expect +$x +### end + +### bashbox_lexer_backslash_escaped +# backslash escaped +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=1; cat <<\E\OF +$x +EOF +### expect +$x +### end + +### bashbox_lexer_partly_quoted +# partly quoted +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=1; cat < + + +p q + +pq +### end + +### bashbox_shell_variables_a_pipeline_sets_one_status_per_command +# a pipeline sets one status per command +true | false | true; echo "${PIPESTATUS[*]}" ${#PIPESTATUS[@]}; declare -p PIPESTATUS +### expect +0 1 0 3 +declare -a PIPESTATUS=([0]="0") +### end + +### bashbox_shell_variables_a_single_command_and_a_negated_one_set_it_too +# a single command and a negated one set it too +false; echo ${PIPESTATUS[@]}; ! true; echo ${PIPESTATUS[@]}; f() { return 3; }; f; echo ${PIPESTATUS[@]} +### expect +1 +0 +3 +### end + +### bashbox_shell_variables_an_assignment_and_a_subshell_each_set_it +# an assignment, ((...)), [[...]] and a subshell each set it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +false | true; x=$(false); echo ${PIPESTATUS[@]}; (( 0 )); echo ${PIPESTATUS[@]}; [[ a == a ]]; echo ${PIPESTATUS[@]}; (exit 3); echo ${PIPESTATUS[@]} +### expect +1 +1 +0 +3 +### end + +### bashbox_shell_variables_other_compound_commands_and_definitions_leave_the_last_one_i +# other compound commands and definitions leave the last one inside them +false | true; { false | true; }; echo ${PIPESTATUS[@]}; false | true; case x in y) ;; esac; echo ${PIPESTATUS[@]}; false | true; f() { :; }; echo ${PIPESTATUS[@]} +### expect +1 0 +1 0 +1 0 +### end + +### bashbox_shell_variables_a_substitution_sees_the_status_from_before_it +# a substitution sees the status from before it +false | true; echo $(echo ${PIPESTATUS[@]}) +### expect +1 0 +### end + +### bashbox_shell_variables_pipefail_changes_but_not_pipestatus_and_the_shell_overwrites +# pipefail changes $? but not PIPESTATUS, and the shell overwrites it even when readonly +set -o pipefail; false | true; echo $? ${PIPESTATUS[@]}; PIPESTATUS=(9 9); echo ${PIPESTATUS[@]}; readonly PIPESTATUS; true | false; echo ${PIPESTATUS[@]} +### expect +1 1 0 +0 +0 1 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-glob.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-glob.test.sh new file mode 100644 index 000000000..b4bfc67ff --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-glob.test.sh @@ -0,0 +1,89 @@ +# BashBox shopt-glob cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_shopt_glob_nullglob_drops_a_pattern_that_matches_nothing +# nullglob drops a pattern that matches nothing +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +shopt -s nullglob; echo a *.nope b; x=(*.nope); echo ${#x[@]}; echo "*.nope" +### expect +a b +0 +*.nope +### end + +### bashbox_shopt_glob_dotglob_lets_match_dotfiles +# dotglob lets * match dotfiles +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +shopt -s dotglob; echo *; echo .*; shopt -u dotglob; echo *; echo .* +### expect +.hid C.TXT a.txt b.txt d +.hid +C.TXT a.txt b.txt d +.hid +### end + +### bashbox_shopt_glob_nocaseglob +# nocaseglob +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +shopt -s nocaseglob; echo *.txt; echo [a-c]*; echo c.txt +### expect +C.TXT a.txt b.txt +C.TXT a.txt b.txt +c.txt +### end + +### bashbox_shopt_glob_globstar_makes_recurse +# globstar makes ** recurse +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +shopt -s globstar; echo **; echo **/*.txt; echo d/**; echo d/**/; shopt -u globstar; echo **/*.txt +### expect +C.TXT a.txt b.txt d d/e d/e/y.txt d/x.txt +a.txt b.txt d/e/y.txt d/x.txt +d/ d/e d/e/y.txt d/x.txt +d/ d/e/ +d/x.txt +### end + +### bashbox_shopt_glob_a_literal_last_part_must_exist +# a literal last part must exist +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +echo */x.txt */nope.txt; echo d/*/; echo "*".txt \*.txt +### expect +d/x.txt */nope.txt +d/e/ +*.txt *.txt +### end + +### bashbox_shopt_glob_extglob_patterns_in_pathnames +# extglob patterns in pathnames +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +shopt -s extglob +echo !(*.txt); echo @(a|b).txt; echo *(a).txt; echo +([a-c]).txt; echo ?(a).txt; echo !(d|*.txt|C*) +echo "@(a)".txt; x='@(a|b).txt'; echo $x; echo "$x"; echo @(nomatch) +### expect +C.TXT d +a.txt b.txt +a.txt +a.txt b.txt +a.txt +!(d|*.txt|C*) +@(a).txt +a.txt b.txt +@(a|b).txt +@(nomatch) +### end + +### bashbox_shopt_glob_without_extglob_the_parentheses_are_literal +# without extglob the parentheses are literal +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +x='@(a).txt'; echo $x +shopt -s extglob +echo $x +### expect +@(a).txt +a.txt +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-pattern.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-pattern.test.sh new file mode 100644 index 000000000..58188e9cc --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-pattern.test.sh @@ -0,0 +1,144 @@ +# BashBox shopt-pattern cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_shopt_pattern_always_understands_extglob +# [[ ]] always understands extglob +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=abc; [[ $x == @(a|b)* ]] && echo y1; [[ $x == +([a-c]) ]] && echo y2; [[ $x == !(a*) ]] || echo y3; [[ "" == !(x) ]] && echo y4; [[ "a|b" == @(a\|b) ]] && echo y5; [[ abab == +(ab|a) ]] && echo y6; [[ b == *(a)b ]] && echo y7; [[ ab == ?(a)b ]] && echo y8 +### expect +y1 +y2 +y3 +y4 +y5 +y6 +y7 +y8 +### end + +### bashbox_shopt_pattern_case_needs_shopt_s_extglob +# case needs shopt -s extglob +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +p='@(abc)'; case abc in $p) echo no;; *) echo plain;; esac +shopt -s extglob +case abc in $p) echo yes;; esac; case abc in @(abc)) echo yes;; esac; case a in !(a)*) echo m1;; esac +### expect +plain +yes +yes +m1 +### end + +### bashbox_shopt_pattern_pattern_removal_and_substitution_with_extglob +# pattern removal and substitution with extglob +shopt -s extglob +x=abc; echo "${x//*(z)/-}|${x/*(z)/-}|${x//!(b)/-}|${x/!(b)/-}|${x#!(b)}|${x##!(b)}|${x%!(b)}|${x%%!(b)}|${x/#!(a)/-}|${x/%!(c)/-}" +echo ${x/@(b|c)/X} ${x//[ac]/Y}; y=aXbXc; echo ${y//!(X)/-} ${x//?(b)/-} +x=aab; echo ${x##+(a)} ${x#+(a)} ${x%%+(b)} ${x/+(a)/-}; z=a; echo "${z//!(a)/-}" "${z/#!(b)/-}" "${x/%!(z)/-}" "${x/!(a*)/-}" "${x/!(*)/-}" +### expect +-a-b-c|-abc|-|-|abc||abc||-|- +aXc YbY +- -a--c +b ab aa -b +-a - - -aab aab +### end + +### bashbox_shopt_pattern_quoted_parts_of_a_pattern_match_literally +# quoted parts of a pattern match literally +[[ abc == "a"* ]] && echo q1; [[ abc == "a*" ]] || echo q2; case "a*" in "a*") echo q3;; esac; case ab in "a"?) echo q4;; esac +p='a*'; [[ abc == $p ]] && echo q5; [[ abc == "$p" ]] || echo q6; [[ x != "x" ]] || echo q7 +### expect +q1 +q2 +q3 +q4 +q5 +q6 +q7 +### end + +### bashbox_shopt_pattern_nocasematch_covers_case_and_substitution_not_removal +# nocasematch covers case, [[ ]] and substitution, not removal +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=ABC; shopt -s nocasematch +case ABC in a*) echo m1;; esac; [[ ABC == ab? ]] && echo m2; [[ ABC =~ ^ab ]] && echo m3; [[ ABC = abc ]] && echo m4; echo ${x#a} ${x/b/z} ${x%c} ${x//b/z} ${x/#a/q}; [[ $x != abc ]]; echo $? +shopt -u nocasematch; [[ ABC == abc ]] || echo m5 +### expect +m1 +m2 +m3 +m4 +ABC AzC ABC AzC qBC +1 +m5 +### end + +### bashbox_shopt_pattern_bracket_expressions_in_removal_patterns +# bracket expressions in removal patterns +x=ABC; echo ${x#[AB]} ${x##[[:upper:]]*} ${x%[!C]C} ${x/[z-a]/q} ${x#[z-a]} +### expect +BC A ABC ABC +### end + +### bashbox_shopt_pattern_array_and_positional_operations_apply_per_element +# array and positional operations apply per element +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- ab ac; echo "${@#a}" "${@/c/x}"; a=(xa ya); echo "${a[@]%a}" "${a[*]^^}"; x=aab; echo ${x/q/z} ${x/#/-} ${x/%/-} "${x//b/\$}"; declare -A m=([k1]=a [k2]=b); for k in "${!m[@]}"; do echo "<$k>"; done +### expect +b c ab ax +x y XA YA +aab -aab aab- aa$ + + +### end + +### bashbox_shopt_pattern_quotes_inside_a_group +# quotes inside a group +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +[[ "b)" == @(a|'b)') ]] && echo q1; [[ 'a"' == @("a\"") ]] && echo q2; [[ 'a|' == @(a\|) ]] && echo q3 +### expect +q1 +q2 +q3 +### end + +### bashbox_shopt_pattern_an_unbalanced_group_from_a_variable_is_literal +# an unbalanced group from a variable is literal +p='@(a'; [[ '@(a' == $p ]] && echo lit +### expect +lit +### end + +### bashbox_shopt_pattern_a_negation_nested_in_another_group +# a negation nested in another group +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +[[ c == @(!(a)|b) ]] && echo y1; [[ a == @(!(a)) ]] || echo y2; [[ ab == !(x)? ]] && echo y3 +### expect +y1 +y2 +y3 +### end + +### bashbox_shopt_pattern_groups_next_to_a_negation +# groups next to a negation +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +[[ abab == !(x)+(ab) ]] && echo y1; [[ ab == @(a)!(x) ]] && echo y2; [[ b == ?(a)!(z) ]] && echo y3; [[ aab == *(a)!(z) ]] && echo y4; [[ ab == +(a|ab)!(z) ]] && echo y5; [[ aaa == !(z)*(a|aa) ]] && echo y6; [[ x == !(z)+(a) ]] || echo y7; [[ ab == !(x)+(a|b) ]] && echo y8 +### expect +y1 +y2 +y3 +y4 +y5 +y6 +y7 +y8 +### end + +### bashbox_shopt_pattern_no_match_leaves_the_value_alone +# no match leaves the value alone +shopt -s extglob +x=abc; echo ${x#!(*)} ${x/#!(*)/-} ${x//!(*)/-} ${x%%!(*)} +### expect +abc abc abc abc +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-shopt.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt.test.sh new file mode 100644 index 000000000..1f632abae --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt.test.sh @@ -0,0 +1,70 @@ +# BashBox shopt cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_shopt_unknown_names_are_reported_but_the_rest_still_apply +# unknown names are reported but the rest still apply +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +shopt -s extglob nope nullglob; echo s=$?; shopt -p nullglob extglob; shopt -q nope; echo s=$? +### expect +s=1 +shopt -s nullglob +shopt -s extglob +s=1 +### end + +### bashbox_shopt_bad_flags +# bad flags +shopt -s -u x; echo s=$?; shopt -x; echo s=$?; shopt -sx; echo s=$? +### expect +s=1 +s=2 +s=2 +### end + +### bashbox_shopt_o_with_a_bad_name +# -o with a bad name +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +shopt -so nope; echo s=$?; shopt -uo nope; echo s=$?; shopt -o nope; echo s=$?; shopt -po nope errexit; echo s=$? +### expect +s=0 +s=0 +s=1 +set +o errexit +s=1 +### end + +### bashbox_shopt_lastpipe_is_accepted +# lastpipe is accepted +shopt -s lastpipe; echo a | read v; echo "[$v]" +### expect +[a] +### end + +### bashbox_shopt_aliases_expand_only_with_expand_aliases_from_the_next_line_o +# aliases expand only with expand_aliases, from the next line on +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +alias e='echo hi' +e there +shopt -s expand_aliases +e there +alias echo='echo [e]'; echo x; x=1 e y > f; cat f +alias c='cat'; c < f; sort -o f f; cat f +### expect +a +b +### end + +### bashbox_sort_c_on_sorted_input +# -c on sorted input +printf 'a\nb\nb\n' | sort -c +### expect +### end + +### bashbox_sort_m_merges_sorted_inputs +# -m merges sorted inputs +printf 'a\nc\ne\n' > f1; printf 'b\nc\nd\n' > f2; sort -m f1 f2 +### expect +a +b +c +c +d +e +### end + +### bashbox_sort_m_does_not_sort +# -m does not sort +printf 'b\na\n' > f1; printf 'c\n' > f2; sort -m f1 f2 +### expect +b +a +c +### end + +### bashbox_sort_m_with_keys_and_reverse +# -m with keys and reverse +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '3 a\n1 b\n' > f1; printf '2 c\n' > f2; sort -m -k1,1nr f1 f2 +### expect +3 a +2 c +1 b +### end + +### bashbox_sort_long_options +# long options +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'b:2\na:10\n' | sort --field-separator=: --key=2 --numeric-sort --reverse +### expect +a:10 +b:2 +### end + +### bashbox_sort_sort_selects_the_comparison +# --sort selects the comparison +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '10\n9\n' | sort --sort=num +### expect +9 +10 +### end + +### bashbox_sort_ignored_performance_options +# ignored performance options +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'b\na\n' | sort -S 1M -T /tmp --parallel=2 --compress-program=gzip +### expect +a +b +### end + +### bashbox_sort_r_groups_equal_lines +# -R groups equal lines +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\na\nb\n' | sort -R | uniq | sort +### expect +a +b +### end + +### bashbox_sort_d_is_compatible_with_v +# -d is compatible with -V +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a-2\na-10\n' | sort -dV +### expect +a-2 +a-10 +### end + +### bashbox_sort_r_with_f +# -R with -f +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nA\nb\na\n' | sort -fR | tr A a | uniq | sort +### expect +a +b +### end + +### bashbox_sort_the_same_tab_twice +# the same tab twice +printf 'b:1\na:2\n' | sort -t: -t: -k2 +### expect +b:1 +a:2 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh new file mode 100644 index 000000000..69396acf4 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh @@ -0,0 +1,19 @@ +# BashBox stat cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_stat_name_needing_double_quotes +# name needing double quotes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf x > "a'b c"; stat -c %N "a'b c" +### expect +"a'b c" +### end + +### bashbox_stat_name_needing_escapes +# name needing escapes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf x > $'a\nb'; stat -c %N $'a\nb' +### expect +'a'$'\n''b' +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh new file mode 100644 index 000000000..565a365c6 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh @@ -0,0 +1,113 @@ +# BashBox syntax-error cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_syntax_error_eval_reports_and_returns_2 +# eval reports and returns 2 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +eval fi; echo $? +### expect +2 +### end + +### bashbox_syntax_error_a_sourced_file_reports_under_its_name_and_returns_2 +# a sourced file reports under its name and returns 2 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo 'if then' > bad.sh; source ./bad.sh; echo $? +### expect +2 +### end + +### bashbox_syntax_error_a_backquoted_substitution_is_parsed_when_it_runs_and_fails_w +# a backquoted substitution is parsed when it runs and fails with 2 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=`fi`; echo "[$x] $?" +### expect +[] 2 +### end + +### bashbox_syntax_error_the_exit_trap_reports_its_own_syntax_error +# the EXIT trap reports its own syntax error +trap fi EXIT; echo a +### expect +a +### end + +### bashbox_syntax_error_the_err_trap_reports_its_own_syntax_error +# the ERR trap reports its own syntax error +trap fi ERR; false; echo $? +### expect +1 +### end + +### bashbox_syntax_error_the_return_trap_reports_its_own_syntax_error +# the RETURN trap reports its own syntax error +f(){ trap fi RETURN; }; f; echo $? +### expect +0 +### end + +### bashbox_syntax_error_a_case_pattern +# a case pattern +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=$(case a in a) echo A;; esac); echo "[$x]" +### expect +[A] +### end + +### bashbox_syntax_error_a_case_pattern_in_double_quotes +# a case pattern in double quotes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo "$(case b in a) echo A;; b) echo B;; esac)" +### expect +B +### end + +### bashbox_syntax_error_a_comment +# a comment +x=$( # comment with ) +echo hi); echo "[$x]" +### expect +[hi] +### end + +### bashbox_syntax_error_a_comment_after_a_command +# a comment after a command +x=$(echo a #) +); echo $x +### expect +a +### end + +### bashbox_syntax_error_a_here_document +# a here-document +x=$(cat <out 2>&1 out 2>&1 < in; + x=1 y= z+=2 +} +### end + +### bashbox_type_printer_pipelines_and_or_lists_and_background_jobs +# pipelines, |&, !, and-or lists and background jobs +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +g() { echo a | grep b |& cat; ! true && false || echo x; sleep 1 & echo bg; } +type g +### expect +g is a function +g () +{ + echo a | grep b 2>&1 | cat; + ! true && false || echo x; + sleep 1 & echo bg +} +### end + +### bashbox_type_printer_a_trailing_and_a_list_of_background_jobs +# a trailing & and a list of background jobs +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +d() { a & } +e() { a & +b & } +type d e +### expect +d is a function +d () +{ + a & +} +e is a function +e () +{ + a & b & +} +### end + +### bashbox_type_printer_if_elif_and_else_nest_like_bash_stores_them +# if, elif and else nest like bash stores them +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +h() { if a; then b; elif c; then d; else e; fi; } +type h +### expect +h is a function +h () +{ + if a; then + b; + else + if c; then + d; + else + e; + fi; + fi +} +### end + +### bashbox_type_printer_for_a_bare_for_while_and_until +# for, a bare for, while and until +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +h() { for i in 1 2; do echo $i; done; for j; do :; done; while x; do y; done; until x; do y; done; } +type h +### expect +h is a function +h () +{ + for i in 1 2; + do + echo $i; + done; + for j in "$@"; + do + :; + done; + while x; do + y; + done; + until x; do + y; + done +} +### end + +### bashbox_type_printer_case_with_alternatives_fallthrough_and_an_empty_body +# case with alternatives, fallthrough and an empty body +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +k() { case $1 in a|b) echo ab;; c) echo c;& d) ;;& *) echo def; esac; case x in (a) ;; esac; case w in esac; } +type k +### expect +k is a function +k () +{ + case $1 in + a | b) + echo ab + ;; + c) + echo c + ;& + d) + + ;;& + *) + echo def + ;; + esac; + case x in + a) + + ;; + esac; + case w in + esac +} +### end + +### bashbox_type_printer_subshells_groups_arithmetic_and_conditionals +# subshells, groups, arithmetic and conditionals +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +k() { ( sub; shell ); { grp; }; (( x++ )); ((y=1)); [[ -n $a && ( $b == c* || ! -f x ) ]]; [[ x ]]; [[ ! ( a == b ) ]]; [[ (a) ]]; } +type k +### expect +k is a function +k () +{ + ( sub; + shell ); + { + grp + }; + (( x++ )); + ((y=1)); + [[ -n $a && ( $b == c* || ! -f x ) ]]; + [[ -n x ]]; + [[ ! ( a == b ) ]]; + [[ ( -n a ) ]] +} +### end + +### bashbox_type_printer_c_style_for_keeps_each_part_as_written_empty_parts_read_1 +# C-style for keeps each part as written, empty parts read 1 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a() { for ((i=0;i<3;i++)); do echo; done; for (( i = 0 ; i < 3 ; i++ )); do :; done; for ((;;)); do break; done; } +type a +### expect +a is a function +a () +{ + for ((i=0; i<3; i++)) + do + echo; + done; + for ((i = 0 ; i < 3 ; i++ )) + do + :; + done; + for ((1; 1; 1)) + do + break; + done +} +### end + +### bashbox_type_printer_a_subshell_body +# a subshell body +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +m() ( echo subshell-body ) +type m +### expect +m is a function +m () +{ + ( echo subshell-body ) +} +### end + +### bashbox_type_printer_a_compound_body_that_is_not_a_group +# a compound body that is not a group +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() if true; then :; fi +g() for i in a; do :; done 2>/dev/null +type f g +### expect +f is a function +f () +{ + if true; then + :; + fi +} +g is a function +g () +{ + for i in a; + do + :; + done 2> /dev/null +} +### end + +### bashbox_type_printer_here_documents_follow_the_line_that_opens_them +# here-documents follow the line that opens them +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +n() { cat < f + lit + Q +echo after; } +type n +### expect +n is a function +n () +{ + cat < f +lit +Q + + echo after +} +### end + +### bashbox_type_printer_a_here_document_ending_the_body +# a here-document ending the body +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +c() { cat <f; echo hi +one +A +two +B +} +w() { cat < f +one +A +two +B + + echo hi +} +w is a function +w () +{ + cat < /dev/null 2>&1 +type q +### expect +q is a function +q () +{ + echo a +} > /dev/null 2>&1 +### end + +### bashbox_type_printer_comments_and_continuations_disappear +# comments and continuations disappear +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +t() { echo # comment +} +v() { + # leading comment + echo one # trailing + echo "multi +line" \ + cont +} +type t v +### expect +t is a function +t () +{ + echo +} +v is a function +v () +{ + echo one; + echo "multi +line" cont +} +### end + +### bashbox_type_printer_quoting_is_kept_as_written +# quoting is kept as written +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +j() { echo a\ b "c\"d" 'e' \$x x=1; } +type j +### expect +j is a function +j () +{ + echo a\ b "c\"d" 'e' \$x x=1 +} +### end + +### bashbox_type_printer_declare_f_prints_every_function_sorted +# declare -f prints every function, sorted +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +b() { :; } +a() { echo; } +declare -f +### expect +a () +{ + echo +} +b () +{ + : +} +### end + +### bashbox_type_printer_declare_f_with_names_failing_quietly_for_a_missing_one +# declare -f with names, failing quietly for a missing one +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +b() { :; } +declare -f b nope; echo "s=$?" +### expect +b () +{ + : +} +s=1 +### end + +### bashbox_type_printer_declare_f_lists_names +# declare -F lists names +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +b() { :; } +a() { :; } +declare -F; declare -F b nope a; echo "s=$?" +### expect +declare -f a +declare -f b +b +a +s=1 +### end + +### bashbox_type_printer_type_reports_each_name +# type reports each name +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +b() { :; } +type b nope; echo "s=$?"; type -t b +### expect +b is a function +b () +{ + : +} +s=1 +function +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh new file mode 100644 index 000000000..893d5144c --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh @@ -0,0 +1,115 @@ +# BashBox unexpand cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_unexpand_single_space_before_stop +# single space before stop +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abcdefg h\n' | unexpand -a +### expect +abcdefg h +### end + +### bashbox_unexpand_single_blank_becomes_tab +# single blank becomes tab +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abcdefg \tx\n' | unexpand -a +### expect +abcdefg x +### end + +### bashbox_unexpand_list +# list +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c\n' | unexpand -t 2,6 +### expect + a b c +### end + +### bashbox_unexpand_list_exhausted +# list exhausted +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c d\n' | unexpand -t 2,6 +### expect + a b c d +### end + +### bashbox_unexpand_extend +# extend +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c d\n' | unexpand -t 2,/3 +### expect + a b c d +### end + +### bashbox_unexpand_increment +# increment +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c d\n' | unexpand -t 2,+3 +### expect + a b c d +### end + +### bashbox_unexpand_obsolete_list +# obsolete list +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c\n' | unexpand -2,6 +### expect + a b c +### end + +### bashbox_unexpand_obsolete_trailing_comma +# obsolete trailing comma +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c\n' | unexpand -2,6, +### expect + a b c +### end + +### bashbox_unexpand_trailing_blanks_without_newline +# trailing blanks without newline +printf 'a ' | unexpand -a | od -c +### expect +0000000 a \t +0000002 +### end + +### bashbox_unexpand_non_blank_stops_leading_conversion +# non-blank stops leading conversion +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'x y\n' | unexpand +### expect +x y +### end + +### bashbox_unexpand_tab_at_stop +# tab at stop +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '\t\t a\n' | unexpand -t 4 +### expect + a +### end + +### bashbox_unexpand_spaces_then_tab +# spaces then tab +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' \ta\n' | unexpand +### expect + a +### end + +### bashbox_unexpand_one_space_then_tab +# one space then tab +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abcdefg \t \tx\n' | unexpand -a +### expect +abcdefg x +### end + +### bashbox_unexpand_blank_past_last_stop +# blank past last stop +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abc d\n' | unexpand -t 2 +### expect +abc d +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh new file mode 100644 index 000000000..1378b66b3 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh @@ -0,0 +1,88 @@ +# BashBox uniq cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_uniq_c_counts_runs +# -c counts runs +printf 'a\na\nb\na\n' | uniq -c +### expect + 2 a + 1 b + 1 a +### end + +### bashbox_uniq_d_keeps_only_repeated_lines +# -d keeps only repeated lines +printf 'a\na\nb\n' | uniq -d +### expect +a +### end + +### bashbox_uniq_u_keeps_only_unique_lines +# -u keeps only unique lines +printf 'a\na\nb\n' | uniq -u +### expect +b +### end + +### bashbox_uniq_adds_a_missing_final_newline +# adds a missing final newline +printf 'x\nx' | uniq +### expect +x +### end + +### bashbox_uniq_empty_input +# empty input +printf '' | uniq +### expect +### end + +### bashbox_uniq_second_operand_is_the_output_file +# second operand is the output file +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\na\n' > in; uniq in out; cat out +### expect +a +### end + +### bashbox_uniq_i_ignores_case +# -i ignores case +printf 'a\nA\nb\n' | uniq -ic +### expect + 2 a + 1 b +### end + +### bashbox_uniq_f_skips_fields +# -f skips fields +printf 'x a\ny a\nz b\n' | uniq -f1 +### expect +x a +z b +### end + +### bashbox_uniq_f_past_the_last_field_compares_empty_keys +# -f past the last field compares empty keys +printf 'a b\nc b\n' | uniq -f 5 +### expect +a b +### end + +### bashbox_uniq_s_skips_bytes +# -s skips bytes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'xa\nya\nzb\n' | uniq --skip-chars=1 +### expect +xa +zb +### end + +### bashbox_uniq_w_compares_a_prefix +# -w compares a prefix +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'ab1\nab2\nac\n' | uniq -w2 +### expect +ab1 +ac +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-utf8.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-utf8.test.sh new file mode 100644 index 000000000..9c1bd0c4a --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-utf8.test.sh @@ -0,0 +1,78 @@ +# BashBox utf8 cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_utf8_length_counts_characters +# length counts characters +x='héllo wörld'; echo ${#x}; y='日本語'; echo ${#y} +### expect +11 +3 +### end + +### bashbox_utf8_array_element_length +# array element length +a=('héllo' 'ö'); echo "${#a[0]} ${#a[1]} ${#a[@]}" +### expect +5 1 2 +### end + +### bashbox_utf8_positional_length +# positional length +set -- 'é' 'ab'; echo ${#1} ${#2} +### expect +1 2 +### end + +### bashbox_utf8_substring_by_character +# substring by character +x='héllo'; echo "${x:1:2}|${x: -3}|${x:0:1}|${x:4}|${x:(-4):2}" +### expect +él|llo|h|o|él +### end + +### bashbox_utf8_substring_of_wide_characters +# substring of wide characters +x='日本語'; echo "${x:1} ${x: -1:1}" +### expect +本語 語 +### end + +### bashbox_utf8_invalid_utf_8_counts_bytes +# invalid UTF-8 counts bytes +x=$'\xff\xfe'; echo ${#x}; y=$'a\xffb'; echo ${#y} "${y:2}" +### expect +2 +3 b +### end + +### bashbox_utf8_patterns_match_characters +# patterns match characters +x='héllo'; echo "${x#?}|${x%??}|${x/?/X}|${x//[é]/E}|${x/#h?/Y}" +### expect +éllo|hél|Xéllo|hEllo|Yllo +### end + +### bashbox_utf8_case_and_patterns +# case and [[ ]] patterns +x='héllo'; case $x in h?llo) echo case;; esac; [[ $x == h?llo ]] && echo glob; [[ $x =~ ^h.llo$ ]] && echo regex +### expect +case +glob +regex +### end + +### bashbox_utf8_filename_globbing_by_character +# filename globbing by character +touch é1 ab1; echo ?1 +### expect +é1 +### end + +### bashbox_utf8_patterns_on_invalid_utf_8 +# patterns on invalid UTF-8 +x=$'\xffa'; echo "${x#?}"; case $x in ?a) echo case;; esac +### expect +a +case +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-wc.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-wc.test.sh new file mode 100644 index 000000000..a0db97347 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-wc.test.sh @@ -0,0 +1,11 @@ +# BashBox wc cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_wc_stdin_reserves_seven_columns +# stdin reserves seven columns +echo hi | wc; echo hi | wc -lc +### expect + 1 1 3 + 1 3 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-word-expander.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-word-expander.test.sh new file mode 100644 index 000000000..b2735fea8 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-word-expander.test.sh @@ -0,0 +1,310 @@ +# BashBox word-expander cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_word_expander_braced_positional_and_counts +# braced positional and counts +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- a b; echo ${1} ${#} ${#@} ${#*} +### expect +a 2 2 2 +### end + +### bashbox_word_expander_positional_slices +# positional slices +set -- a b c; echo ${@:2} ${*: -1} +### expect +b c c +### end + +### bashbox_word_expander_name_i_is_not_an_array_reference +# $name[i] is not an array reference +a=(x y); echo $a[1] +### expect +x[1] +### end + +### bashbox_word_expander_bare_array_name_is_element_0 +# bare array name is element 0 +a=(x y); echo $a ${a} +### expect +x x +### end + +### bashbox_word_expander_substring_with_negative_offset_length +# substring with negative offset/length +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +s=hello; echo ${s: -2} ${s:1:-1} ${s: -3:2} "[${s: -10}]" ${s:1+1:2} +### expect +lo ell ll [] ll +### end + +### bashbox_word_expander_array_slices +# array slices +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=(1 2 3 4); echo ${a[@]: -2} ${a[@]:1:2} "[${a[@]: -9}]" +### expect +3 4 2 3 [] +### end + +### bashbox_word_expander_subscripts_are_arithmetic +# subscripts are arithmetic +a=(x y z); i=1; echo ${a[$i]} ${a[i]} ${a[i+1]} ${a[-1]} +### expect +y y z z +### end + +### bashbox_word_expander_associative_subscript_is_expanded +# associative subscript is expanded +declare -A m; m[k]=v; key=k; echo ${m[$key]} ${m[k]} +### expect +v v +### end + +### bashbox_word_expander_element_length +# element length +a=(foo bar); echo ${#a[1]} +### expect +3 +### end + +### bashbox_word_expander_keys_and_counts +# keys and counts +a=(b a); echo ${!a[@]} ${#a[@]} ${a[*]} +### expect +0 1 2 b a +### end + +### bashbox_word_expander_indirection +# indirection +r=x; x=val; echo "[${!r}]" +### expect +[val] +### end + +### bashbox_word_expander_indirection_to_an_element +# indirection to an element +a=(p q); r="a[1]"; echo ${!r} +### expect +q +### end + +### bashbox_word_expander_nested_default +# nested default +x=; echo ${x:-${y:-z}} +### expect +z +### end + +### bashbox_word_expander_element_default +# element default +a=(p q); echo ${a[0]:-d} ${a[5]:-d} +### expect +p d +### end + +### bashbox_word_expander_set_vs_null_tests +# set vs null tests +unset x; echo ${x-unset} ${x+set}; x=; echo ${x-unset} ${x+set} ${x:+nonempty} +### expect +unset +set +### end + +### bashbox_word_expander_empty_uses_default +# empty $@ uses default +set --; echo "[${@-x}] [${@:-y}]" +### expect +[x] [y] +### end + +### bashbox_word_expander_assign_default +# assign default +echo ${z=assigned} $z +### expect +assigned assigned +### end + +### bashbox_word_expander_assign_default_to_element +# assign default to element +echo ${a[1]:=x} ${a[1]} +### expect +x x +### end + +### bashbox_word_expander_error_if_unset_passes_a_set_value +# error-if-unset passes a set value +x=v; echo ${x?never} ${x:?never} +### expect +v v +### end + +### bashbox_word_expander_pattern_operand_is_expanded +# pattern operand is expanded +p=b; x=abc; echo ${x#*$p} +### expect +c +### end + +### bashbox_word_expander_quoted_pattern_characters_are_literal +# quoted pattern characters are literal +x='a*b'; echo ${x#*\*} ${x%"*"b} +### expect +b a +### end + +### bashbox_word_expander_prefix_suffix_removal +# prefix/suffix removal +x=a.b.c; echo ${x#*.} ${x##*.} ${x%.*} ${x%%.*} ${x#?} +### expect +b.c c a.b a .b.c +### end + +### bashbox_word_expander_anchored_replacement +# anchored replacement +x=abc; echo ${x/#a/X} ${x/%c/Y} ${x/b} +### expect +Xbc abY ac +### end + +### bashbox_word_expander_replacement_is_not_a_regex_template +# replacement is not a regex template +x=abc; r='\0$1'; echo ${x/b/$r} +### expect +a\0$1c +### end + +### bashbox_word_expander_replace_first_all +# replace first/all +x=abab; echo ${x/b/X} ${x//b/X} +### expect +aXab aXaX +### end + +### bashbox_word_expander_case_modification +# case modification +x=hello; echo ${x^} ${x^^} ${x,} ${x,,} +### expect +Hello HELLO hello hello +### end + +### bashbox_word_expander_nested_arithmetic_parens +# nested arithmetic parens +echo $(( ((1+2)) * 3 )) $(( (1+(2)) )) +### expect +9 3 +### end + +### bashbox_word_expander_quoted_parens_in +# quoted parens in $(...) +echo $(echo ")" "(" '(') $(echo "a)b\")") +### expect +) ( ( a)b") +### end + +### bashbox_word_expander_joins_with_first_ifs_char +# "$*" joins with first IFS char +set -- a b; IFS=:; echo "$*" +### expect +a:b +### end + +### bashbox_word_expander_trailing_backslash_pattern +# trailing backslash pattern +x="a\\"; p="\\"; echo ${x%$p} +### expect +a +### end + +### bashbox_word_expander_lone_or_unknown +# lone or unknown $ +echo a$%b $ +### expect +a$%b $ +### end + +### bashbox_word_expander_unset_variable_is_empty +# unset variable is empty +echo $nothing. +### expect +. +### end + +### bashbox_word_expander_double_quote_escapes +# double-quote escapes +echo "a\"b\$c\\d\q" +### expect +a"b$c\d\q +### end + +### bashbox_word_expander_backtick_keeps_other_backslashes +# backtick keeps other backslashes +x=`printf "%s" "a\qb"`; echo $x +### expect +a\qb +### end + +### bashbox_word_expander_nested_backticks +# nested backticks +x=`echo \`echo hi\``; echo $x +### expect +hi +### end + +### bashbox_word_expander_ifs_splitting_with_non_whitespace +# IFS splitting with non-whitespace +IFS=:; x=":a::b:"; printf "[%s]" $x; echo +### expect +[][a][][b] +### end + +### bashbox_word_expander_empty_unquoted_word_vanishes +# empty unquoted word vanishes +x=; printf "[%s]" $x a; echo +### expect +[a] +### end + +### bashbox_word_expander_empty_ifs_does_not_split +# empty IFS does not split +IFS=; x="a b"; printf "[%s]" $x; echo +### expect +[a b] +### end + +### bashbox_word_expander_whitespace_ifs_collapses +# whitespace IFS collapses +x=" a b "; printf "[%s]" $x; echo +### expect +[a][b] +### end + +### bashbox_word_expander_keeps_words +# "$@" keeps words +f(){ for x in "$@"; do echo "[$x]"; done; }; f a "b c" +### expect +[a] +[b c] +### end + +### bashbox_word_expander_escaped_comma_and_non_expanding_braces +# escaped comma and non-expanding braces +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo {a\,b,c} {a}b {abc} {1..c} +### expect +a,b c {a}b {abc} {1..c} +### end + +### bashbox_word_expander_descending_ranges +# descending ranges +echo {5..1} {c..a} +### expect +5 4 3 2 1 c b a +### end + +### bashbox_word_expander_stepped_ranges +# stepped ranges +echo {1..9..3} {a..e..2} {1..5..-2} +### expect +1 4 7 a c e 1 3 5 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh new file mode 100644 index 000000000..a6c6ca98c --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh @@ -0,0 +1,344 @@ +# BashBox xargs cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_xargs_defaults_to_echo +# defaults to echo +printf 'a b\n c' | xargs +### expect +a b c +### end + +### bashbox_xargs_appends_items_to_the_command +# appends items to the command +echo a b | xargs echo X +### expect +X a b +### end + +### bashbox_xargs_n_limits_items_per_run +# -n limits items per run +echo a b c | xargs -n 2; echo a b | xargs -n1 echo - +### expect +a b +c +- a +- b +### end + +### bashbox_xargs_options_after_the_command_belong_to_it +# options after the command belong to it +echo a b | xargs -n1 echo -n; echo +### expect +ab +### end + +### bashbox_xargs_i_runs_once_per_line_keeping_trailing_blanks +# -I runs once per line, keeping trailing blanks +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'one\n\n two \n' | xargs -I {} echo '<{}>' {} +### expect + one + two +### end + +### bashbox_xargs_i_with_an_attached_placeholder +# -I with an attached placeholder +echo x | xargs -I% echo [%] +### expect +[x] +### end + +### bashbox_xargs_runs_once_on_empty_input +# runs once on empty input +printf '' | xargs echo hi +### expect +hi +### end + +### bashbox_xargs_r_skips_empty_input +# -r skips empty input +printf '' | xargs -r echo hi +### expect +### end + +### bashbox_xargs_quotes_and_backslashes_group_words +# quotes and backslashes group words +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '%s\n' '"a b" '\''c d'\'' e\\ f g\\\\h' 'i\\' 'j' | xargs -n1 echo +### expect +a b +c d +e\ +f +g\\h +i\ +j +### end + +### bashbox_xargs_an_empty_quoted_word_is_kept_but_a_final_one_is_dropped +# an empty quoted word is kept but a final one is dropped +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '"" a\nb ""' | xargs -n1 echo -; printf "c '" | xargs echo +### expect +- +- a +- b +c +### end + +### bashbox_xargs_a_backslash_escapes_a_newline +# a backslash escapes a newline +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\\\nb\n' | xargs -n1 echo; printf 'c\\' | xargs echo +### expect +a +b +c +### end + +### bashbox_xargs_a_nul_ends_an_argument_with_a_warning +# a NUL ends an argument, with a warning +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\0b c\0d\n' | xargs echo +### expect +a c +### end + +### bashbox_xargs_0_splits_at_nuls +# -0 splits at NULs +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b\0\0c\0' | xargs -0 -n1 echo - +### expect +- a b +- +- c +### end + +### bashbox_xargs_d_takes_a_character_or_an_escape +# -d takes a character or an escape +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a,b,,c' | xargs -d, echo; printf 'a\tb\n' | xargs -d '\t' echo; printf 'aXb' | xargs -d '\x58' echo; printf 'aXb' | xargs --delimiter='\130' echo; printf 'a\ab' | xargs -d '\a' echo +### expect +a b c +a b + +a b +a b +a b +### end + +### bashbox_xargs_d_numbers_are_read_like_strtoul +# -d numbers are read like strtoul +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'aAb' | xargs -d '\x 41' echo; printf 'aAb' | xargs -d '\x+0x41' echo; printf 'a\0b' | xargs -d '\x' echo; printf 'a\0b' | xargs -d '\x-0' echo +### expect +a b +a b +a b +a b +### end + +### bashbox_xargs_d_items_keep_blanks_and_quotes +# -d items keep blanks and quotes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf "a 'b\nc\n" | xargs -d '\n' -n1 echo +### expect +a 'b +c +### end + +### bashbox_xargs_e_stops_at_the_end_of_file_word +# -E stops at the end-of-file word +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b\nSTOP\nc\n' | xargs -E STOP echo; printf 'a STOP b\n' | xargs -eSTOP echo; printf 'STOP\nc\n' | xargs --eof=STOP echo x; printf 'a STOP' | xargs -E STOP; printf 'STOP' | xargs -E STOP echo y +### expect +a b +a +x +a STOP +y +### end + +### bashbox_xargs_e_on_a_line_ending +# -E on a line ending +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a STOP\nb\n' | xargs -E STOP -L1 echo +### expect +a +### end + +### bashbox_xargs_a_bare_e_turns_e_off +# a bare -e turns -E off +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a x b | xargs -E x -e echo; echo a x b | xargs -E x --eof echo +### expect +a x b +a x b +### end + +### bashbox_xargs_e_has_no_effect_with_0_or_d +# -E has no effect with -0 or -d +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\0x\0b' | xargs -E x -0 echo +### expect +a x b +### end + +### bashbox_xargs_l_runs_per_lines +# -L runs per lines +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\nc\nd\ne\n' | xargs -L2 echo +### expect +a b +c d +e +### end + +### bashbox_xargs_l_continues_a_line_ending_in_a_blank +# -L continues a line ending in a blank +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b \nc\nd\\ \ne\n' | xargs -L1 echo +### expect +a b c +d e +### end + +### bashbox_xargs_l_defaults_to_one_line +# -l defaults to one line +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\nc\n' | xargs -l echo; printf 'a\nb\nc\n' | xargs -l2 echo; printf 'a\nb\n' | xargs --max-lines echo +### expect +a +b +c +a b +c +a +b +### end + +### bashbox_xargs_a_bare_optional_option_at_the_end_of_a_bundle +# a bare optional option at the end of a bundle +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a x b | xargs -E x -tl echo; echo y | xargs -ti echo {}; echo z | xargs -it echo {} +### expect +a +y +{} +### end + +### bashbox_xargs_l_n_and_i_exclude_each_other +# -L, -n and -I exclude each other +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\nc\n' | xargs -n2 -L1 echo; printf 'a\nb\nc\n' | xargs -L1 -n2 echo; printf 'a\nb\n' | xargs -I{} -l echo {} +### expect +a +b +c +a b +c +{} a +{} b +### end + +### bashbox_xargs_i_excludes_n_and_l +# -I excludes -n and -L +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\n' | xargs -L1 -I{} echo {}; printf 'a\nb\n' | xargs -n2 -I{} echo {}; printf 'a\nb\n' | xargs -I{} -n2 echo {} +### expect +a +b +a +b +{} a b +### end + +### bashbox_xargs_n1_after_i_is_ignored +# -n1 after -I is ignored +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b\nc\n' | xargs -I{} -n1 echo [{}] +### expect +[a b] +[c] +### end + +### bashbox_xargs_i_defaults_to +# -i defaults to {} +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\n' | xargs -i echo {}; echo c | xargs --replace echo {}; echo d | xargs -iX echo X +### expect +a +b +c +d +### end + +### bashbox_xargs_numbers_may_have_blanks_and_a_sign +# numbers may have blanks and a sign +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a b c | xargs -n ' +2' echo; echo d | xargs -P 2 echo +### expect +a b +c +d +### end + +### bashbox_xargs_s_limits_the_command_length +# -s limits the command length +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\nc\n' | xargs -s 9 echo +### expect +a b +c +### end + +### bashbox_xargs_x_alone_still_splits +# -x alone still splits +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b c d\n' | xargs -x -s 9 echo +### expect +a b +c d +### end + +### bashbox_xargs_i_size_limits +# -I size limits +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abc\n' | xargs -I{} -s 8 echo {} {}; printf 'abcde\n' | xargs -I{} -s 6 echo x{}; printf 'abcdef\n' | xargs -I{} -s 6 echo x{}; printf 'a\n' | xargs -I{} -s 3 echo {}; printf '' | xargs -I{} -s 3 echo {} +### expect +### end + +### bashbox_xargs_i_with_0 +# -I with -0 +printf 'a b\0c\0' | xargs -0 -I{} echo [{}] +### expect +[a b] +[c] +### end + +### bashbox_xargs_a_reads_standard_input +# -a - reads standard input +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\n' | xargs -a - echo +### expect +a b +### end + +### bashbox_xargs_a_with_a_directory_reads_nothing +# -a with a directory reads nothing +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +mkdir d; xargs -a d echo x +### expect +x +### end + +### bashbox_xargs_t_prints_each_command +# -t prints each command +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b\nc\n' | xargs -t -n2 echo; printf "it's\ta b\n" | xargs -d '\n' --verbose +### expect +a b +c +it's a b +### end diff --git a/knowledge/operations/testing.md b/knowledge/operations/testing.md index c27e6ded1..c9b7bc915 100644 --- a/knowledge/operations/testing.md +++ b/knowledge/operations/testing.md @@ -295,6 +295,26 @@ Quarterly import process: 4. CI runs the same feature-complete, network-free lane. Schema, provenance, unique IDs, classifications, and limitation references are test-enforced. +## Imported BashBox Corpus + +`spec_cases/bash/bashbox-*.test.sh` holds bash-parity cases copied from the +test suite of [BashBox](https://github.com/shipfastlabs/bashbox) (MIT, PHP port +of just-bash; attribution in `NOTICE`). One file per BashBox test file. +Imported 2026-10-07: 1,753 (script, stdout) pairs extracted, kept only those +whose recorded stdout matched host bash 5.2 under `LANG=C.UTF-8`, and whose +expectation is representable in the spec format (1,044 cases). + +- 624 pass in Bashkit and run in both the spec suite and the bash comparison gate. +- 420 are `### skip: TODO bashbox corpus gap` — Bashkit's stdout or exit status + differs from real bash. These are bugs, not intentional divergences: fixing + one means deleting its `skip` line (the comparison gate then guards it). + Clusters: `declare`/`local`/attributes, `sort` and `xargs` flags, arithmetic on + arrays, `declare -f`/`type` printing, named fds, ERR trap, `printf`, `tr`. + +Do not re-import over existing files; fix cases in place. If an intentional +divergence is found, switch the case to `### bash_diff` with a canonical `L-*` +row in [Known Limitations](limitations.md). + ## SQLite CLI Differential Tests CSV comparisons explicitly set the host CLI row separator to LF. SQLite From ec6e2178ae29b177d426f8f08180aab25c1201c9 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 19:34:59 +0000 Subject: [PATCH 2/6] fix(arith): treat a sign after an operator as unary (3 - -1) --- crates/bashkit/src/interpreter/arithmetic.rs | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/bashkit/src/interpreter/arithmetic.rs b/crates/bashkit/src/interpreter/arithmetic.rs index 90f500300..62b870239 100644 --- a/crates/bashkit/src/interpreter/arithmetic.rs +++ b/crates/bashkit/src/interpreter/arithmetic.rs @@ -681,6 +681,34 @@ impl Interpreter { /// Try to parse a binary operator at the current precedence level. /// Scans `chars`/`bo` for operators, splitting and recursing. /// Returns `Some(value)` if an operator was found, `None` to try next level. + /// Whether the `+`/`-` at `i` is a sign rather than a binary operator: + /// nothing but another operator precedes it (`3 - -1`, `2 * -x`). A + /// postfix `x++ - 1` still leaves the `-` binary. + fn arith_sign_is_unary(chars: &[char], i: usize) -> bool { + let operand_end = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | ')' | ']'); + let mut j = i; + while j > 0 && chars[j - 1].is_ascii_whitespace() { + j -= 1; + } + if j == 0 { + return true; + } + let p = chars[j - 1]; + if operand_end(p) { + return false; + } + if matches!(p, '+' | '-') && j >= 2 && chars[j - 2] == p { + let mut k = j - 2; + while k > 0 && chars[k - 1].is_ascii_whitespace() { + k -= 1; + } + if k > 0 && operand_end(chars[k - 1]) { + return false; + } + } + true + } + pub(super) fn try_parse_arith_addmul( &self, expr: &str, @@ -708,6 +736,10 @@ impl Interpreter { if chars[i] == '-' && i > 0 && chars[i - 1] == '-' { continue; } + // `3 - -1`: a sign after another operator is unary. + if Self::arith_sign_is_unary(chars, i) { + continue; + } let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); return Some(if chars[i] == '+' { From 5f2668f754fba0df4511551970d5045ebe855bca Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 19:35:07 +0000 Subject: [PATCH 3/6] feat(parser): print function bodies like bash and close bashbox parser gaps - type / declare -f print function bodies via a print_cmd.c port (parser/print_cmd.rs); words keep source text while parsing function bodies; function bodies may be any compound command with redirections - (( )) and for (( )) read raw up to the matching )); heredoc delimiter quoting decided from its source text; several heredocs per command - ]] closes [[ only as a whole word; |& is 2>&1 | - backquoted bodies that fail to parse error at run time (status 2) - process substitution ends where the substitution scanner closes it - ${x:-$(cmd)} runs operand substitutions only when the operand is used; quoted replacement text keeps } inside '...' - LINENO honors local/unset; script children keep caller cwd and shared stdin position; command ./script runs scripts; eval/source parse errors exit 2 - simple-command word/redirect handling outlined so nested $( ) parsing uses less stack than before --- crates/bashkit/src/builtins/introspect.rs | 8 +- .../src/interpreter/brace_expansion.rs | 1 + crates/bashkit/src/interpreter/expansion.rs | 82 +- crates/bashkit/src/interpreter/mod.rs | 117 +- crates/bashkit/src/parser/ast.rs | 45 +- crates/bashkit/src/parser/lexer.rs | 177 ++- crates/bashkit/src/parser/mod.rs | 1007 +++++++++++------ crates/bashkit/src/parser/print_cmd.rs | 941 +++++++++++++++ crates/bashkit/src/parser/raw.rs | 151 +++ crates/bashkit/src/parser/tokens.rs | 3 + .../bash/bashbox-compound-syntax.test.sh | 2 - .../spec_cases/bash/bashbox-lexer.test.sh | 4 - .../bash/bashbox-line-number.test.sh | 1 - .../spec_cases/bash/bashbox-parser.test.sh | 2 - .../bash/bashbox-script-file.test.sh | 4 - .../bash/bashbox-syntax-error.test.sh | 7 - .../bash/bashbox-type-printer.test.sh | 25 - .../tests/spec_cases/bash/type.test.sh | 11 +- 18 files changed, 2155 insertions(+), 433 deletions(-) create mode 100644 crates/bashkit/src/parser/print_cmd.rs create mode 100644 crates/bashkit/src/parser/raw.rs diff --git a/crates/bashkit/src/builtins/introspect.rs b/crates/bashkit/src/builtins/introspect.rs index 3b506480b..541cda26a 100644 --- a/crates/bashkit/src/builtins/introspect.rs +++ b/crates/bashkit/src/builtins/introspect.rs @@ -66,6 +66,7 @@ impl Builtin for Type { } let mut output = String::new(); + let mut errors = String::new(); let mut all_found = true; for name in &names { @@ -105,6 +106,10 @@ impl Builtin for Type { let mut found_any = false; if is_func { output.push_str(&format!("{} is a function\n", name)); + if let Some(text) = shell.function_text(name) { + output.push_str(&text); + output.push('\n'); + } found_any = true; } if is_kw && (show_all || !found_any) { @@ -127,7 +132,7 @@ impl Builtin for Type { found_any = true; } if !found_any { - output.push_str(&format!("bash: type: {}: not found\n", name)); + errors.push_str(&format!("bash: type: {}: not found\n", name)); all_found = false; } } @@ -136,6 +141,7 @@ impl Builtin for Type { let exit_code = if all_found { 0 } else { 1 }; Ok(ExecResult { stdout: output.into(), + stderr: errors.into(), exit_code, ..Default::default() }) diff --git a/crates/bashkit/src/interpreter/brace_expansion.rs b/crates/bashkit/src/interpreter/brace_expansion.rs index 70a0e75dc..3b59829be 100644 --- a/crates/bashkit/src/interpreter/brace_expansion.rs +++ b/crates/bashkit/src/interpreter/brace_expansion.rs @@ -241,6 +241,7 @@ impl Interpreter { quoted: word.quoted, has_unquoted_glob: word.has_unquoted_glob, part_quoted, + raw: None, }); } Some(words) diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index cfb7ac4b4..dfdfba176 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -370,6 +370,20 @@ impl Interpreter { self.nounset_error = Some(format!("bash: {}: unbound variable\n", name)); } + if operand.contains("$(") { + // Boxed: keeps this frame small for deep `$(...)` nesting + // (TM-DOS-089). + Box::pin(self.prefetch_operand_substs( + operator, + operand, + *colon_variant, + is_set, + &value, + )) + .await?; + } else { + self.operand_substs.clear(); + } // Delegate to sync helper to avoid bloating the async state // machine with Vec locals (causes stack overflow at // depth 32 in debug builds — see stack_overflow_regression_tests). @@ -703,6 +717,7 @@ impl Interpreter { quoted: part_is_quoted, has_unquoted_glob: false, part_quoted: vec![part_is_quoted], + raw: None, }; self.expand_word(&single).await? }; @@ -1086,6 +1101,52 @@ impl Interpreter { /// Expand an operand string from a parameter expansion (sync, lazy). /// Only called when the operand is actually needed, providing lazy evaluation. + /// `${x:-$(cmd)}`: run the operand's command substitutions before the + /// sync operand expansion, only when the operator uses the operand (so + /// an unused default never runs). Outputs queue in `operand_substs`. + async fn prefetch_operand_substs( + &mut self, + operator: &ParameterOp, + operand: &str, + colon_variant: bool, + is_set: bool, + value: &str, + ) -> Result<()> { + self.operand_substs.clear(); + if !operand.contains("$(") { + return Ok(()); + } + let unset_or_null = !is_set || (colon_variant && value.is_empty()); + let uses_operand = match operator { + ParameterOp::UseDefault | ParameterOp::AssignDefault | ParameterOp::Error => { + unset_or_null + } + ParameterOp::UseReplacement => !unset_or_null, + _ => false, + }; + if !uses_operand { + return Ok(()); + } + let (word, _, _) = Self::parse_marked_operand( + operand, + self.limits.max_ast_depth, + self.limits.max_parser_operations, + ); + for part in &word.parts { + if let WordPart::CommandSubstitution(commands) = part { + // THREAT[TM-DOS-088]: same depth accounting as word `$(...)`. + if self.counters.push_subst(&self.limits).is_err() { + return Err(crate::error::Error::Execution( + "maximum command substitution depth exceeded".to_string(), + )); + } + let out = self.execute_cmd_subst(commands).await?; + self.operand_substs.push_back(out); + } + } + Ok(()) + } + pub(super) fn expand_operand(&mut self, operand: &str) -> String { if operand.is_empty() { return String::new(); @@ -1143,7 +1204,14 @@ impl Interpreter { let value = self.expand_variable(name).len().to_string(); Self::push_operand_expansion(&mut result, &value, in_marked || force_quoted); } - // TODO: handle CommandSubstitution etc. in sync operand expansion + // Run ahead by `prefetch_operand_substs` (default-family + // operators only); other operators leave the queue empty. + WordPart::CommandSubstitution(_) => { + if let Some(out) = self.operand_substs.pop_front() { + Self::push_operand_expansion(&mut result, &out, in_marked || force_quoted); + } + } + // TODO: process substitution in sync operand expansion _ => {} } } @@ -1533,7 +1601,7 @@ impl Interpreter { replacement, } => { // ${var/pattern/replacement} - replace first occurrence - let expanded_rep = self.expand_operand(replacement); + let expanded_rep = self.expand_replacement_operand(replacement); let expanded_pat = self.expand_replace_pattern(pattern); self.replace_pattern(value, &expanded_pat, &expanded_rep, false) } @@ -1542,7 +1610,7 @@ impl Interpreter { replacement, } => { // ${var//pattern/replacement} - replace all occurrences - let expanded_rep = self.expand_operand(replacement); + let expanded_rep = self.expand_replacement_operand(replacement); let expanded_pat = self.expand_replace_pattern(pattern); self.replace_pattern(value, &expanded_pat, &expanded_rep, true) } @@ -1589,6 +1657,13 @@ impl Interpreter { self.expand_operand(&Self::single_quotes_as_quoted(operand)) } + /// Expand the replacement of `${x/pattern/rep}`. Like the pattern, + /// bash quote-removes `'...'` there even inside double quotes + /// (`"${y/b/'}'}"` is `a}`). + pub(super) fn expand_replacement_operand(&mut self, operand: &str) -> String { + self.expand_operand(&Self::single_quotes_as_quoted(operand)) + } + /// Expand the pattern of `${x/pattern/rep}`. A leading `#`/`%` in the /// source is the anchor; a `#`/`%` that comes from expansion is literal. pub(super) fn expand_replace_pattern(&mut self, pattern: &str) -> String { @@ -2146,6 +2221,7 @@ mod expansion_charge_tests { quoted, has_unquoted_glob, part_quoted: Vec::new(), + raw: None, } } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 633c6bbd5..a91aaad6f 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -481,6 +481,13 @@ impl ShellRef<'_> { self.functions.contains_key(name) } + /// A function's definition as bash prints it (`type`, `declare -f`). + pub(crate) fn function_text(&self, name: &str) -> Option { + self.functions + .get(name) + .map(|f| crate::parser::function_string(name, &f.body)) + } + /// Check if a name is a shell keyword. pub(crate) fn is_keyword(&self, name: &str) -> bool { is_keyword(name) @@ -1397,6 +1404,9 @@ pub struct Interpreter { jobs: SharedJobTable, /// Current line number for $LINENO current_line: usize, + /// `unset LINENO` makes it an ordinary variable for the rest of the + /// shell's life, like bash. + lineno_unset: bool, /// HTTP client for network builtins (curl, wget) #[cfg(feature = "http_client")] http_client: Option>, @@ -1447,6 +1457,9 @@ pub struct Interpreter { output_stream_stderr_bytes: usize, /// Pending nounset (set -u) error message, consumed by execute_command. nounset_error: Option, + /// Outputs of the `$(...)` parts of a `${x:-...}`-family operand, run + /// ahead (async) and consumed in order by the sync `expand_operand`. + operand_substs: std::collections::VecDeque, /// PIPESTATUS: exit codes of the last pipeline's commands pipestatus: Vec, /// Aliases currently being expanded (prevents infinite recursion). @@ -1986,6 +1999,7 @@ impl Interpreter { ), jobs: jobs::new_shared_job_table(), current_line: 1, + lineno_unset: false, #[cfg(feature = "http_client")] http_client: None, #[cfg(feature = "git")] @@ -2007,6 +2021,7 @@ impl Interpreter { output_stream_stdout_bytes: 0, output_stream_stderr_bytes: 0, nounset_error: None, + operand_substs: std::collections::VecDeque::new(), pipestatus: Vec::new(), expanding_aliases: HashSet::new(), regex_cache: RuntimeRegexCache::default(), @@ -2143,6 +2158,7 @@ impl Interpreter { execution_budget: self.execution_budget.clone(), jobs: self.jobs.fork(), current_line: self.current_line, + lineno_unset: self.lineno_unset, #[cfg(feature = "http_client")] http_client: self.http_client.clone(), #[cfg(feature = "git")] @@ -2162,6 +2178,7 @@ impl Interpreter { output_stream_stdout_bytes: 0, output_stream_stderr_bytes: 0, nounset_error: None, + operand_substs: std::collections::VecDeque::new(), pipestatus: Vec::new(), expanding_aliases: HashSet::new(), regex_cache: RuntimeRegexCache::default(), @@ -3583,7 +3600,10 @@ impl Interpreter { CompoundCommand::BraceGroup(commands) => self.execute_command_sequence(commands).await, CompoundCommand::Case(case_cmd) => self.execute_case(case_cmd).await, CompoundCommand::Select(select_cmd) => self.execute_select(select_cmd).await, - CompoundCommand::Arithmetic(expr) => self.execute_arithmetic_command(expr).await, + CompoundCommand::Arithmetic(expr) => { + self.execute_arithmetic_command(&crate::parser::arith_exec_text(expr)) + .await + } CompoundCommand::Time(time_cmd) => self.execute_time(time_cmd).await, CompoundCommand::Conditional(words) => self.execute_conditional(words).await, CompoundCommand::Coproc(coproc_cmd) => self.execute_coproc(coproc_cmd).await, @@ -8036,6 +8056,8 @@ impl Interpreter { let saved_env = self.env.clone(); let saved_memory_budget = self.memory_budget.clone(); let saved_exec_fd_table = self.exec_fd_table.clone(); + // The child process has its own working directory (`cd` stays there). + let saved_cwd = self.cwd.clone(); // Child only sees exported variables (env), not all shell variables. // Reset last_exit_code so $? starts at 0 (matches real bash subprocess). @@ -8073,9 +8095,13 @@ impl Interpreter { // Forward pipeline stdin so commands inside the script (cat, read, etc.) can consume it let prev_pipeline_stdin = self.pipeline_stdin.take(); + // Stdin shared with the caller (no pipe or redirect of its own): what + // the child's `read` consumes is gone for the caller too. + let shares_caller_stdin = stdin.is_some() && stdin == prev_pipeline_stdin; self.pipeline_stdin = stdin; let result = self.execute_script_body(&script, true, false).await; + let child_stdin_left = self.pipeline_stdin.take(); // Restore full parent state — child mutations don't propagate self.scoped.variables = saved_vars; @@ -8094,7 +8120,12 @@ impl Interpreter { self.memory_budget = saved_memory_budget; self.exec_fd_table = saved_exec_fd_table; self.bash_source_stack = saved_source_stack; - self.pipeline_stdin = prev_pipeline_stdin; + self.cwd = saved_cwd; + self.pipeline_stdin = if shares_caller_stdin { + child_stdin_left + } else { + prev_pipeline_stdin + }; match result { Ok(mut exec_result) => { @@ -8185,7 +8216,7 @@ impl Interpreter { Err(crate::error::Error::Parse { message, .. }) => { return Ok(ExecResult::err( format!("source: {}: parse error: {}", filename, message), - 1, + 2, )); } Err(e) => return Err(e), @@ -8272,7 +8303,7 @@ impl Interpreter { Err(crate::error::Error::Parse { message, .. }) => { return Ok(ExecResult::err( format!("eval: parse error: {}", message), - 1, + 2, )); } Err(e) => return Err(e), @@ -9008,6 +9039,9 @@ impl Interpreter { exit_code = 1; continue; } + if resolved == "LINENO" { + self.lineno_unset = true; + } self.vars_mut().remove(&resolved); self.env.remove(&resolved); // THREAT[TM-DOS-114]: `unset arr` must return the array's entry @@ -9408,6 +9442,27 @@ impl Interpreter { ) .await; } + // `command ./script` / `command name-on-PATH`: run the file, + // like a plain command would. + if self.shell_features.has_script_execution() { + let builtin_args = builtin_args.to_vec(); + if target.contains('/') { + return self + .try_execute_script_by_path(target, &builtin_args, _stdin, redirects) + .await; + } + if let Some(result) = self + .try_execute_script_via_path_search( + target, + &builtin_args, + _stdin, + redirects, + ) + .await? + { + return Ok(result); + } + } Ok(ExecResult::err( format!("bash: {}: command not found\n", remaining[0]), 127, @@ -9449,6 +9504,7 @@ impl Interpreter { let mut is_readonly = false; let mut is_export = false; let mut is_function = false; + let mut function_names_only = false; let mut flags = DeclareFlags::default(); let mut remove_nameref = false; let mut is_lowercase = false; @@ -9464,6 +9520,10 @@ impl Interpreter { 'r' => is_readonly = true, 'x' => is_export = true, 'f' => is_function = true, + 'F' => { + is_function = true; + function_names_only = true; + } 'l' => is_lowercase = true, 'u' => is_uppercase = true, _ => {} // n, a, A, i handled by flags @@ -9482,29 +9542,40 @@ impl Interpreter { } // declare -f: function display mode + // `-F` lists names (`declare -f NAME` for all), `-f` prints bodies + // the way bash's print_cmd lays them out; a missing name prints + // nothing and makes the status 1. if is_function { let mut output = String::new(); + let mut exit_code = 0; if names.is_empty() { - // List all functions let mut func_names: Vec<_> = self.scoped.functions.keys().cloned().collect::>(); func_names.sort(); for fname in &func_names { - output.push_str(&format!("{} ()\n{{\n ...\n}}\n", fname)); + if function_names_only { + output.push_str(&format!("declare -f {fname}\n")); + } else if let Some(f) = self.scoped.functions.get(fname) { + output.push_str(&crate::parser::function_string(fname, &f.body)); + output.push('\n'); + } } } else { - // Print specific functions — return 1 if any not found for name in &names { - if self.scoped.functions.contains_key(*name) { - output.push_str(&format!("{} ()\n{{\n ...\n}}\n", name)); - } else { - let mut result = ExecResult::with_code(String::new(), 1); - result = self.apply_redirections(result, redirects).await?; - return Ok(result); + match self.scoped.functions.get(*name) { + Some(_) if function_names_only => { + output.push_str(name); + output.push('\n'); + } + Some(f) => { + output.push_str(&crate::parser::function_string(name, &f.body)); + output.push('\n'); + } + None => exit_code = 1, } } } - let mut result = ExecResult::ok(output); + let mut result = ExecResult::with_code(output, exit_code); result = self.apply_redirections(result, redirects).await?; return Ok(result); } @@ -11230,8 +11301,18 @@ impl Interpreter { } return u32::from_le_bytes(b).to_string(); } - "LINENO" => { - // $LINENO - current line number from command span + // $LINENO - current line number from command span. Assignments + // do not stick, but a function's `local LINENO` holds its value + // and `unset LINENO` makes it ordinary. + "LINENO" if !self.lineno_unset => { + if let Some(v) = self + .call_stack + .iter() + .rev() + .find_map(|frame| frame.locals.get("LINENO")) + { + return v.clone(); + } return self.current_line.to_string(); } "PWD" => { @@ -11289,6 +11370,9 @@ impl Interpreter { // Resolve nameref before checking — a nameref whose target exists is "set". let name = self.resolve_nameref(name); + if name == "LINENO" && !self.lineno_unset { + return true; + } // Special variables are always "set" if matches!( name, @@ -11300,7 +11384,6 @@ impl Interpreter { | "-" | "RANDOM" | "SRANDOM" - | "LINENO" | "PWD" | "OLDPWD" | "HOSTNAME" diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index ff7189f99..71e3d6baf 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -112,7 +112,8 @@ pub enum CompoundCommand { Subshell(Vec), /// Brace group BraceGroup(Vec), - /// Arithmetic command ((expression)) + /// Arithmetic command ((expression)), text as written between the + /// parentheses; evaluate `parser::arith_exec_text` of it. Arithmetic(String), /// Time command - measure execution time // Keep the recursive command enum small: time's GNU option payload is @@ -200,6 +201,9 @@ pub struct ArithmeticForCommand { pub condition: String, /// Step/update expression pub step: String, + /// The three clauses as written (for `type`), when read from source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw: Option>, /// Loop body pub body: Vec, /// Source span of this command @@ -283,6 +287,10 @@ pub struct Word { /// fall back to `quoted` for every part. #[serde(default)] pub part_quoted: Vec, + /// Source text as written. Only recorded inside function definitions, + /// where `type`/`declare -f` print words the way bash does (as written). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw: Option, } impl Word { @@ -293,6 +301,7 @@ impl Word { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, } } @@ -303,6 +312,7 @@ impl Word { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, } } } @@ -524,6 +534,10 @@ pub struct Redirect { pub kind: RedirectKind, /// Target (file, fd, or heredoc content) pub target: Word, + /// Here-document delimiter as bash prints it (`EOF`, or `'EOF'` when the + /// delimiter was quoted). Only set for `<<`/`<<-`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub heredoc_delim: Option, } /// Types of redirections. @@ -613,6 +627,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "$HOME"); } @@ -624,6 +639,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "$((1+2))"); } @@ -635,6 +651,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${#var}"); } @@ -649,6 +666,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${arr[0]}"); } @@ -660,6 +678,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${#arr[@]}"); } @@ -671,6 +690,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${!arr[@]}"); } @@ -686,6 +706,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:2:3}"); } @@ -701,6 +722,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:2}"); } @@ -716,6 +738,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${arr[@]:1:2}"); } @@ -731,6 +754,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${arr[@]:1}"); } @@ -747,6 +771,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${!ref}"); } @@ -758,6 +783,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${!MY_*}"); } @@ -772,6 +798,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var@Q}"); } @@ -786,6 +813,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "hello $USER"); } @@ -802,6 +830,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:-fallback}"); } @@ -818,6 +847,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var-fallback}"); } @@ -834,6 +864,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:=val}"); } @@ -850,6 +881,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:+alt}"); } @@ -866,6 +898,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var:?msg}"); } @@ -883,6 +916,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var#pat}"); @@ -897,6 +931,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var##pat}"); @@ -911,6 +946,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var%pat}"); @@ -925,6 +961,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var%%pat}"); } @@ -944,6 +981,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var/old/new}"); @@ -960,6 +998,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), "${var///old/new}"); } @@ -977,6 +1016,7 @@ mod tests { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }; assert_eq!(format!("{w}"), expected); }; @@ -1012,6 +1052,7 @@ mod tests { fd_var: None, kind: RedirectKind::Output, target: Word::literal("out.txt"), + heredoc_delim: None, }], assignments: vec![], span: Span::new(), @@ -1141,6 +1182,7 @@ mod tests { fd_var: None, kind: RedirectKind::Input, target: Word::literal("input.txt"), + heredoc_delim: None, }; assert!(r.fd.is_none()); assert_eq!(r.kind, RedirectKind::Input); @@ -1256,6 +1298,7 @@ mod tests { init: "i=0".into(), condition: "i<10".into(), step: "i++".into(), + raw: None, body: vec![], span: Span::new(), }; diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index a5843c2f5..383583372 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -12,6 +12,9 @@ use super::tokens::Token; pub struct SpannedToken { pub token: Token, pub span: Span, + /// Source text of a word token, only while raw capture is on (function + /// bodies, for `type`/`declare -f` printing). + pub raw: Option, } /// Maximum nesting depth for command substitution in the lexer. @@ -32,6 +35,30 @@ struct ContinuationFlags { error: Option, } +/// Bash parses a backquoted command when it runs, so a syntax error there +/// fails only that substitution (status 2), unlike `$(...)` which fails the +/// whole command at parse time. A body that does not parse becomes +/// `eval 'body'`, which reports the same error when (and only if) it runs. +fn defer_backtick_syntax_error(buf: &mut String, body_start: usize) { + let Some(body) = buf.get(body_start..) else { + return; + }; + if body.trim().is_empty() || super::Parser::new(body).parse().is_ok() { + return; + } + let deferred = format!("eval {}", super::raw::single_quote(body)); + buf.truncate(body_start); + buf.push_str(&deferred); +} + +/// Concrete (drop-free) type of [`Lexer::lookahead`], so borrows end early. +type Lookahead<'s, 'a> = std::iter::Peekable< + std::iter::Chain< + std::iter::Copied>, + std::iter::Peekable>, + >, +>; + /// Lexer for bash scripts. pub struct Lexer<'a> { #[allow(dead_code)] // Stored for error reporting in future @@ -44,6 +71,11 @@ pub struct Lexer<'a> { reinject_buf: VecDeque, /// Maximum allowed nesting depth for command substitution max_subst_depth: usize, + /// Record each word token's source text (see [`SpannedToken::raw`]). + /// Important decision: captured from consumed chars, not span offsets, + /// so text re-injected after a heredoc body still maps to its own token. + capture_raw: bool, + raw_buf: String, } impl<'a> Lexer<'a> { @@ -61,6 +93,8 @@ impl<'a> Lexer<'a> { chars: input.chars().peekable(), reinject_buf: VecDeque::new(), max_subst_depth: max_depth, + capture_raw: false, + raw_buf: String::new(), } } @@ -91,19 +125,53 @@ impl<'a> Lexer<'a> { }; if let Some(c) = ch { self.position.advance(c); + if self.capture_raw { + self.raw_buf.push(c); + } } ch } + /// Upcoming characters without consuming them: re-injected text first + /// (the rest of a heredoc line), then the input. + fn lookahead(&self) -> Lookahead<'_, 'a> { + self.reinject_buf + .iter() + .copied() + .chain(self.chars.clone()) + .peekable() + } + + /// Whether word-token source capture is on. + pub fn capture_raw(&self) -> bool { + self.capture_raw + } + + /// Turn word-token source capture on or off. + pub fn set_capture_raw(&mut self, on: bool) { + self.capture_raw = on; + } + /// Get the next token with its source span. pub fn next_spanned_token(&mut self) -> Option { self.skip_whitespace(); let start = self.position; + self.raw_buf.clear(); let token = self.next_token_inner()?; let end = self.position; + let raw = (self.capture_raw + && matches!( + token, + Token::Word(_) + | Token::LiteralWord(_) + | Token::QuotedWord(_) + | Token::QuotedGlobWord(_) + )) + .then(|| std::mem::take(&mut self.raw_buf)); Some(SpannedToken { token, span: Span::from_positions(start, end), + raw, }) } @@ -138,6 +206,9 @@ impl<'a> Lexer<'a> { if self.peek_char() == Some('|') { self.advance(); Some(Token::Or) + } else if self.peek_char() == Some('&') { + self.advance(); + Some(Token::PipeBoth) } else { Some(Token::Pipe) } @@ -246,7 +317,7 @@ impl<'a> Lexer<'a> { self.advance(); // `[[` is the keyword only as a whole word; `[[:digit:]]*` is // a glob bracket expression. - let mut lookahead = self.chars.clone(); + let mut lookahead = self.lookahead(); let keyword = lookahead.next() == Some('[') && matches!( lookahead.next(), @@ -278,12 +349,24 @@ impl<'a> Lexer<'a> { } } ']' => { + // `]]` closes `[[` only as a whole word; `]]x` and `]x` are + // ordinary words, like bash. + let ends_word = |c: Option| { + matches!( + c, + None | Some(' ' | '\t' | '\n' | ';' | '|' | '&' | '(' | ')' | '<' | '>') + ) + }; self.advance(); - if self.peek_char() == Some(']') { + let mut ahead = self.lookahead(); + let double = ahead.next() == Some(']') && ends_word(ahead.next()); + if double { self.advance(); Some(Token::DoubleRightBracket) - } else { + } else if ends_word(self.peek_char()) { Some(Token::Word("]".to_string())) + } else { + self.read_word_starting_with("]") } } '\'' => self.read_single_quoted_string(), @@ -305,7 +388,7 @@ impl<'a> Lexer<'a> { self.advance(); } else if ch == '\\' { // Check for backslash-newline (line continuation) between tokens - let mut lookahead = self.chars.clone(); + let mut lookahead = self.lookahead(); lookahead.next(); // skip backslash if lookahead.next() == Some('\n') { self.advance(); // consume backslash @@ -347,7 +430,7 @@ impl<'a> Lexer<'a> { // 2-char redirect operator (e.g. ">>", "<&", "<<") matter, so bound the // lookahead — collecting all remaining input here made every // digit-initial word O(n) and the whole lex O(n^2) (TM-DOS-024). - let input_remaining: String = self.chars.clone().take(4).collect(); + let input_remaining: String = self.lookahead().take(4).collect(); // Check patterns: "N>" "N>>" "N>&" "N<" "N<&" if fd_str.len() == 1 @@ -897,6 +980,7 @@ impl<'a> Lexer<'a> { has_unquoted_expansion = true; self.advance(); // consume opening ` word.push_str("$("); + let body_start = word.len(); let mut closed = false; while let Some(c) = self.peek_char() { if c == '`' { @@ -927,6 +1011,7 @@ impl<'a> Lexer<'a> { "unterminated backtick substitution".to_string(), )); } + defer_backtick_syntax_error(&mut word, body_start); word.push(')'); } else if ch == '\\' { self.advance(); @@ -1208,7 +1293,7 @@ impl<'a> Lexer<'a> { } Some('$') => { // Check for $'...' ANSI-C quoting in continuation - let mut lookahead = self.chars.clone(); + let mut lookahead = self.lookahead(); lookahead.next(); // skip $ if lookahead.next() == Some('\'') { self.advance(); // consume $ @@ -1428,6 +1513,15 @@ impl<'a> Lexer<'a> { } depth += 1; } + // `${x:-$(echo })}`: braces inside a command substitution + // belong to it. + '$' if self.peek_char() == Some('(') => { + self.advance(); + word.push_str("$("); + if !self.read_command_subst_body(word) { + return Err("unterminated command substitution".to_string()); + } + } '}' => { word.push('}'); depth -= 1; @@ -1653,6 +1747,7 @@ impl<'a> Lexer<'a> { // Backtick command substitution inside double quotes self.advance(); // consume opening ` content.push_str("$("); + let body_start = content.len(); while let Some(c) = self.peek_char() { if c == '`' { self.advance(); @@ -1675,6 +1770,7 @@ impl<'a> Lexer<'a> { self.advance(); } } + defer_backtick_syntax_error(&mut content, body_start); content.push(')'); } _ => { @@ -1860,6 +1956,19 @@ impl<'a> Lexer<'a> { false } + /// Consume a `<(...)`/`>(...)` body after its opening token, through the + /// closing `)`, with the same scanner as `$(...)`. Nothing is copied; the + /// caller slices the source. Returns whether the `)` was found. + pub fn skip_subst_body(&mut self) -> bool { + let mut scanner = super::subst_scan::SubstScanner::new(); + while let Some(c) = self.advance() { + if scanner.feed(c) == super::subst_scan::Step::Close { + return true; + } + } + false + } + /// Read parameter expansion content after `${`, handling nested braces and quotes. /// In bash, quotes inside `${...}` (e.g. `${arr["key"]}`) don't terminate the /// outer double-quoted string. Appends chars including closing `}` to `content`. @@ -1978,7 +2087,7 @@ impl<'a> Lexer<'a> { /// Check if { is followed by whitespace (brace group start) fn is_brace_group_start(&self) -> bool { - let mut chars = self.chars.clone(); + let mut chars = self.lookahead(); // Skip the opening { if chars.next() != Some('{') { return false; @@ -1995,7 +2104,7 @@ impl<'a> Lexer<'a> { /// and `>`; `}` is not among them, so it delimits a word only when a /// metacharacter or EOF already follows it. fn right_brace_stands_alone(&self) -> bool { - let mut chars = self.chars.clone(); + let mut chars = self.lookahead(); if chars.next() != Some('}') { return false; } @@ -2043,7 +2152,7 @@ impl<'a> Lexer<'a> { // over leading whitespace made `x=(` followed by megabytes of spaces // O(n) per call (TM-DOS-024). const MAX_LOOKAHEAD: usize = 10_000; - let mut chars = self.chars.clone(); + let mut chars = self.lookahead(); // Skip the `(` we haven't consumed yet if chars.next() != Some('(') { return false; @@ -2059,6 +2168,54 @@ impl<'a> Lexer<'a> { false } + /// Read the body of `(( ... ))` as written, after the opening `((`, + /// through the matching `))` (consumed, not returned). Parentheses nest; + /// quoted text is kept verbatim. Bash reads the arithmetic command this + /// way, so `<<` is a shift and spacing survives for `type`. Returns + /// `None` when input ends first. + pub fn read_dparen_body(&mut self) -> Option { + let mut body = String::new(); + let mut depth = 0usize; + while let Some(c) = self.advance() { + match c { + '(' => depth += 1, + ')' if depth == 0 && self.peek_char() == Some(')') => { + self.advance(); + return Some(body); + } + // An unbalanced `)` stays text. + ')' if depth == 0 => {} + ')' => depth -= 1, + '\\' => { + body.push(c); + if let Some(n) = self.advance() { + body.push(n); + } + continue; + } + '\'' | '"' => { + body.push(c); + while let Some(n) = self.advance() { + body.push(n); + if n == c { + break; + } + if n == '\\' + && c == '"' + && let Some(e) = self.advance() + { + body.push(e); + } + } + continue; + } + _ => {} + } + body.push(c); + } + None + } + /// Read the raw source of a `[[ ... =~ REGEX ]]` operand, as bash does: /// one word that ends at unquoted whitespace or `;&<>` outside /// parentheses. Inside parentheses spaces belong to the regex; `|` and @@ -2081,7 +2238,7 @@ impl<'a> Lexer<'a> { } if ch == '|' { // `||` ends the operand; a lone `|` is alternation. - let mut ahead = self.chars.clone(); + let mut ahead = self.lookahead(); ahead.next(); if self.reinject_buf.is_empty() && ahead.peek() == Some(&'|') { break; diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index efde7798e..c602e2016 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -16,6 +16,8 @@ mod ast; pub mod budget; mod lexer; +mod print_cmd; +mod raw; mod span; mod subst_scan; mod tokens; @@ -23,11 +25,13 @@ mod tokens; pub use ast::*; pub use budget::{BudgetError, validate as validate_budget}; pub use lexer::{Lexer, SpannedToken}; +pub use print_cmd::function_string; pub use span::{Position, Span}; use crate::error::{Error, Result}; use crate::limits::LimitExceeded; use crate::time_compat::Instant; +use raw::{heredoc_eof_from_raw, single_quote, split_raw_words}; use std::cell::Cell; use std::time::Duration; @@ -57,6 +61,11 @@ pub struct Parser<'a> { current_span: Span, /// Lookahead token for function parsing peeked_token: Option, + /// Source text of the current word token (only inside function bodies). + current_raw: Option, + /// Nesting depth of function definitions being parsed; word source text + /// is captured while it is non-zero. + function_depth: usize, /// Maximum allowed AST nesting depth max_depth: usize, /// Current nesting depth @@ -128,6 +137,8 @@ impl<'a> Parser<'a> { current_token, current_span, peeked_token: None, + current_raw: None, + function_depth: 0, max_depth: max_depth.min(HARD_MAX_AST_DEPTH), current_depth: 0, fuel: max_fuel, @@ -186,6 +197,27 @@ impl<'a> Parser<'a> { } } + /// Attach the current token's source text to a word built from it. + fn with_raw(&self, mut word: Word) -> Word { + if word.raw.is_none() { + word.raw = self.current_raw.clone(); + } + word + } + + /// Start capturing word source text for a function body. + fn enter_function_body(&mut self) { + self.function_depth += 1; + self.lexer.set_capture_raw(true); + } + + fn leave_function_body(&mut self) { + self.function_depth = self.function_depth.saturating_sub(1); + if self.function_depth == 0 { + self.lexer.set_capture_raw(false); + } + } + fn source_slice(&self, start_offset: usize, end_offset: usize) -> Option { self.input.get(start_offset..end_offset).map(str::to_owned) } @@ -356,14 +388,17 @@ impl<'a> Parser<'a> { if let Some(peeked) = self.peeked_token.take() { self.current_token = Some(peeked.token); self.current_span = peeked.span; + self.current_raw = peeked.raw; } else { match self.lexer.next_spanned_token() { Some(st) => { self.current_token = Some(st.token); self.current_span = st.span; + self.current_raw = st.raw; } None => { self.current_token = None; + self.current_raw = None; // Keep the last span for error reporting } } @@ -501,7 +536,15 @@ impl<'a> Parser<'a> { let mut commands = vec![first]; - while matches!(self.current_token, Some(tokens::Token::Pipe)) { + while matches!( + self.current_token, + Some(tokens::Token::Pipe | tokens::Token::PipeBoth) + ) { + if matches!(self.current_token, Some(tokens::Token::PipeBoth)) + && let Some(prev) = commands.last_mut() + { + add_stderr_to_pipe(prev); + } self.advance(); self.skip_newlines()?; @@ -541,6 +584,7 @@ impl<'a> Parser<'a> { fd_var: None, kind, target, + heredoc_delim: None, }); } } @@ -552,6 +596,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Append, target, + heredoc_delim: None, }); } } @@ -563,6 +608,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Input, target, + heredoc_delim: None, }); } } @@ -574,6 +620,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::OutputBoth, target, + heredoc_delim: None, }); } } @@ -585,6 +632,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupOutput, target, + heredoc_delim: None, }); } } @@ -597,6 +645,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Output, target, + heredoc_delim: None, }); } } @@ -609,6 +658,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Append, target, + heredoc_delim: None, }); } } @@ -621,6 +671,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupOutput, target: Word::literal(dst_fd.to_string()), + heredoc_delim: None, }); } Some(tokens::Token::DupFdCloseOut(fd)) => { @@ -631,6 +682,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupOutput, target: Word::literal("-"), + heredoc_delim: None, }); } Some(tokens::Token::DupInput) => { @@ -641,6 +693,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupInput, target, + heredoc_delim: None, }); } } @@ -653,6 +706,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupInput, target: Word::literal(dst_fd.to_string()), + heredoc_delim: None, }); } Some(tokens::Token::DupFdClose(fd)) => { @@ -663,6 +717,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupInput, target: Word::literal("-"), + heredoc_delim: None, }); } Some(tokens::Token::RedirectFdIn(fd)) => { @@ -674,6 +729,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Input, target, + heredoc_delim: None, }); } } @@ -685,55 +741,14 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::HereString, target, + heredoc_delim: None, }); } } Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) => { let strip_tabs = matches!(self.current_token, Some(tokens::Token::HereDocStrip)); - self.advance(); - let (delimiter, quoted) = match &self.current_token { - Some(tokens::Token::Word(w)) => (w.clone(), false), - Some(tokens::Token::LiteralWord(w)) => (w.clone(), true), - Some(tokens::Token::QuotedWord(w)) - | Some(tokens::Token::QuotedGlobWord(w)) => (w.clone(), true), - _ => break, - }; - let (content, rest_of_line_chars) = self - .lexer - .read_heredoc_with_strip_metered(&delimiter, strip_tabs); - self.tick_units(rest_of_line_chars)?; - let content = if strip_tabs { - let had_trailing_newline = content.ends_with('\n'); - let mut stripped: String = content - .lines() - .map(|l| l.trim_start_matches('\t')) - .collect::>() - .join("\n"); - if had_trailing_newline { - stripped.push('\n'); - } - stripped - } else { - content - }; - self.advance(); - let target = if quoted { - Word::quoted_literal(content) - } else { - self.parse_word(content) - }; - let kind = if strip_tabs { - RedirectKind::HereDocStrip - } else { - RedirectKind::HereDoc - }; - redirects.push(Redirect { - fd: None, - fd_var: None, - kind, - target, - }); + self.parse_heredoc_redirect(strip_tabs, &mut redirects)?; // Rest-of-line tokens re-injected by lexer; break so callers // can see pipes/semicolons. break; @@ -946,7 +961,7 @@ impl<'a> Parser<'a> { if matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))) { word.has_unquoted_glob = true; } - words.push(word); + words.push(self.with_raw(word)); self.advance(); } Some(tokens::Token::LiteralWord(w)) => { @@ -955,6 +970,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), }); self.advance(); } @@ -1050,7 +1066,7 @@ impl<'a> Parser<'a> { if matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))) { word.has_unquoted_glob = true; } - words.push(word); + words.push(self.with_raw(word)); self.advance(); } Some(tokens::Token::LiteralWord(w)) => { @@ -1059,6 +1075,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), }); self.advance(); } @@ -1100,14 +1117,31 @@ impl<'a> Parser<'a> { /// Parse C-style arithmetic for loop inner: for ((init; cond; step)); do body; done /// Note: depth tracking is done by parse_for which calls this fn parse_arithmetic_for_inner(&mut self, start_span: Span) -> Result { - self.advance(); // consume '((' - // Read the three expressions separated by semicolons let mut parts: Vec = Vec::new(); + let mut raw_parts: Option> = None; let mut current_expr = String::new(); let mut paren_depth = 0; - loop { + if self.peeked_token.is_none() { + // Read `((init; cond; step))` as written (see read_dparen_body). + let Some(body) = self.lexer.read_dparen_body() else { + return Err(Error::parse( + "unexpected end of input in for loop".to_string(), + )); + }; + let raws = split_arith_for_parts(&body); + if raws.len() != 3 { + return Err(self.error("syntax error: arithmetic expression required")); + } + parts = raws.iter().map(|p| arith_exec_text(p)).collect(); + raw_parts = Some(raws); + self.advance(); + } else { + self.advance(); // consume '((' + } + + while raw_parts.is_none() { match &self.current_token { Some(tokens::Token::DoubleRightParen) => { // End of the (( )) section @@ -1231,6 +1265,7 @@ impl<'a> Parser<'a> { init, condition, step, + raw: raw_parts, body, span: start_span.merge(self.current_span), })) @@ -1349,6 +1384,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), }, Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) @@ -1368,7 +1404,7 @@ impl<'a> Parser<'a> { } _ => unreachable!(), }; - patterns.push(pattern); + patterns.push(self.with_raw(pattern)); self.advance(); // Check for | between patterns @@ -1688,7 +1724,7 @@ impl<'a> Parser<'a> { if w_clone.contains('$') && !is_quoted && !is_literal { // Variable reference — parse normally for expansion let parsed = self.parse_word(w_clone); - words.push(parsed); + words.push(self.with_raw(parsed)); self.advance(); } else { let pattern = self.collect_conditional_regex_pattern(&w_clone); @@ -1704,7 +1740,9 @@ impl<'a> Parser<'a> { // pattern text, not comments or operators. words.push(Word::literal("=~")); if let Some(raw) = self.lexer.read_cond_regex() { - words.push(self.cond_regex_word(raw)); + let mut word = self.cond_regex_word(raw.clone()); + word.raw = Some(raw.trim().to_string()); + words.push(word); } self.advance(); continue; @@ -1719,6 +1757,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), } } else { let mut parsed = self.parse_word(w_clone); @@ -1730,7 +1769,7 @@ impl<'a> Parser<'a> { } parsed }; - words.push(word); + words.push(self.with_raw(word)); self.advance(); } // Operators that the lexer tokenizes separately @@ -1852,6 +1891,17 @@ impl<'a> Parser<'a> { } fn parse_arithmetic_command(&mut self) -> Result { + if self.peeked_token.is_none() { + // Read `(( expr ))` as written (see read_dparen_body); the + // command keeps that text, so `type` prints it unchanged. + let Some(body) = self.lexer.read_dparen_body() else { + return Err(Error::parse( + "unexpected end of input in arithmetic command".to_string(), + )); + }; + self.advance(); + return Ok(CompoundCommand::Arithmetic(body)); + } self.advance(); // consume '((' // Read expression until we find )) @@ -2012,33 +2062,15 @@ impl<'a> Parser<'a> { Some(tokens::Token::Word(w)) => w.clone(), _ => return Err(self.error("expected function name")), }; - self.advance(); - self.skip_newlines()?; - - // Optional () after name - if matches!(self.current_token, Some(tokens::Token::LeftParen)) { - self.advance(); // consume '(' - if !matches!(self.current_token, Some(tokens::Token::RightParen)) { - return Err(Error::parse( - "expected ')' in function definition".to_string(), - )); - } - self.advance(); // consume ')' - self.skip_newlines()?; - } - - // Expect { for body - if !matches!(self.current_token, Some(tokens::Token::LeftBrace)) { - return Err(Error::parse("expected '{' for function body".to_string())); - } - - // Parse body as brace group - let body = self.parse_brace_group()?; + self.enter_function_body(); + let body = self.parse_function_keyword_rest(); + self.leave_function_body(); + let body = body?; let end_offset = self.current_command_end_offset(); Ok(Command::Function(FunctionDef { name, - body: Box::new(Command::Compound(body, Vec::new())), + body: Box::new(body), source: self.source_slice(start_span.start.offset, end_offset), span: start_span.merge(self.current_span), })) @@ -2063,26 +2095,71 @@ impl<'a> Parser<'a> { if !matches!(self.current_token, Some(tokens::Token::RightParen)) { return Err(self.error("expected ')' in function definition")); } - self.advance(); // consume ')' - self.skip_newlines()?; - - // Expect { for body - if !matches!(self.current_token, Some(tokens::Token::LeftBrace)) { - return Err(self.error("expected '{' for function body")); - } - - // Parse body as brace group - let body = self.parse_brace_group()?; + self.enter_function_body(); + let body = self.parse_function_posix_rest(); + self.leave_function_body(); + let body = body?; let end_offset = self.current_command_end_offset(); Ok(Command::Function(FunctionDef { name, - body: Box::new(Command::Compound(body, Vec::new())), + body: Box::new(body), source: self.source_slice(start_span.start.offset, end_offset), span: start_span.merge(self.current_span), })) } + /// `function name [()] body`, after the name. + fn parse_function_keyword_rest(&mut self) -> Result { + self.advance(); + self.skip_newlines()?; + + // Optional () after name + if matches!(self.current_token, Some(tokens::Token::LeftParen)) { + self.advance(); // consume '(' + if !matches!(self.current_token, Some(tokens::Token::RightParen)) { + return Err(Error::parse( + "expected ')' in function definition".to_string(), + )); + } + self.advance(); // consume ')' + self.skip_newlines()?; + } + self.parse_function_body() + } + + /// `name () body`, from the `)`. + fn parse_function_posix_rest(&mut self) -> Result { + self.advance(); // consume ')' + self.skip_newlines()?; + self.parse_function_body() + } + + /// A function body: any compound command with its redirections + /// (`f() ( sub )`, `f() if ...; fi`, `f() { ...; } >log`), like bash. + fn parse_function_body(&mut self) -> Result { + let compound_start = match &self.current_token { + Some( + tokens::Token::LeftBrace + | tokens::Token::LeftParen + | tokens::Token::DoubleLeftParen + | tokens::Token::DoubleLeftBracket, + ) => true, + Some(tokens::Token::Word(w)) => matches!( + w.as_str(), + "if" | "for" | "while" | "until" | "case" | "select" + ), + _ => false, + }; + if !compound_start { + return Err(self.error("expected '{' for function body")); + } + match self.parse_command()? { + Some(cmd @ Command::Compound(..)) => Ok(cmd), + _ => Err(self.error("expected '{' for function body")), + } + } + /// Parse commands until a terminating keyword fn parse_compound_list(&mut self, terminator: &str) -> Result> { self.parse_compound_list_until(&[terminator]) @@ -2319,6 +2396,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), } } else if matches!( &self.current_token, @@ -2330,7 +2408,7 @@ impl<'a> Parser<'a> { } else { self.parse_word(elem_clone) }; - elements.push(word); + elements.push(self.with_raw(word)); self.advance(); } None => break, @@ -2350,6 +2428,19 @@ impl<'a> Parser<'a> { /// must drop again so `x="a*"b*` stores `a*b*`. fn try_parse_assignment(&mut self, w: &str, glob_escaped: bool) -> Option<(Assignment, bool)> { let (name, index, value, is_append) = Self::is_assignment(w)?; + // Source text of the value (after `=`), for function printing. + let value_raw = self.current_raw.as_deref().map(|raw| { + let start = match index { + Some(_) => raw.find("]=").or_else(|| raw.find("]+=")).map(|i| i + 1), + None => raw.find(['=', '+']), + } + .unwrap_or(0); + let rest = &raw[start..]; + rest.strip_prefix("+=") + .or_else(|| rest.strip_prefix('=')) + .unwrap_or(rest) + .to_string() + }); let name = name.to_string(); let index = index.map(|s| s.to_string()); let value_str = value.to_string(); @@ -2357,7 +2448,7 @@ impl<'a> Parser<'a> { // Array literal in the token itself: arr=(a b c) if value_str.starts_with('(') && value_str.ends_with(')') { let inner = &value_str[1..value_str.len() - 1]; - let elements: Vec = Self::split_array_elements(inner) + let mut elements: Vec = Self::split_array_elements(inner) .into_iter() .map(|(s, quoted)| { if quoted { @@ -2369,6 +2460,18 @@ impl<'a> Parser<'a> { } }) .collect(); + if let Some(raw) = value_raw.as_deref() { + let raw_inner = raw + .strip_prefix('(') + .and_then(|r| r.strip_suffix(')')) + .unwrap_or(raw); + let raws = split_raw_words(raw_inner); + if raws.len() == elements.len() { + for (elem, r) in elements.iter_mut().zip(raws) { + elem.raw = Some(r); + } + } + } return Some(( Assignment { name, @@ -2397,11 +2500,13 @@ impl<'a> Parser<'a> { )); } // Empty assignment: VAR= + let mut empty = Word::literal(""); + empty.raw = value_raw; return Some(( Assignment { name, index, - value: AssignmentValue::Scalar(Word::literal("")), + value: AssignmentValue::Scalar(empty), append: is_append, }, false, @@ -2409,7 +2514,7 @@ impl<'a> Parser<'a> { } // Quoted or plain scalar value - let value_word = if value_str.starts_with('"') && value_str.ends_with('"') { + let mut value_word = if value_str.starts_with('"') && value_str.ends_with('"') { let inner = Self::strip_quotes(&value_str); let mut w = self.parse_word(inner.to_string()); w.quoted = true; @@ -2421,6 +2526,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, } } else { let mut w = self.parse_word(value_str); @@ -2433,6 +2539,7 @@ impl<'a> Parser<'a> { } w }; + value_word.raw = value_raw; Some(( Assignment { name, @@ -2447,13 +2554,18 @@ impl<'a> Parser<'a> { /// Parse a compound array argument in arg position (e.g. `declare -a arr=(x y z)`). /// Called when the current word ends with `=` and the next token is `(`. /// Returns the compound word if successful, or `None` if not a compound assignment. - fn try_parse_compound_array_arg(&mut self, saved_w: String) -> Option { + fn try_parse_compound_array_arg( + &mut self, + saved_w: String, + saved_raw: Option, + ) -> Option { if !matches!(self.current_token, Some(tokens::Token::LeftParen)) { return None; } self.advance(); // consume '(' let mut compound = saved_w; compound.push('('); + let mut raw_elems: Vec = Vec::new(); loop { match &self.current_token { Some(tokens::Token::RightParen) => { @@ -2469,6 +2581,7 @@ impl<'a> Parser<'a> { compound.push(' '); } compound.push_str(elem); + raw_elems.push(self.current_raw.clone().unwrap_or_default()); self.advance(); } None => break, @@ -2477,7 +2590,9 @@ impl<'a> Parser<'a> { } } } - Some(self.parse_word(compound)) + let mut word = self.parse_word(compound); + word.raw = saved_raw.map(|r| format!("{r}({})", raw_elems.join(" "))); + Some(word) } /// Parse a heredoc redirect (`<<` or `<<-`) and any trailing redirects on the same line. @@ -2486,7 +2601,13 @@ impl<'a> Parser<'a> { strip_tabs: bool, redirects: &mut Vec, ) -> Result<()> { + // Capture the delimiter's source text: bash quote-removes it and + // treats the body as literal when any part of it was quoted + // (`<<'E'`, `<<\E`, `< (w.clone(), false), @@ -2496,6 +2617,25 @@ impl<'a> Parser<'a> { } _ => return Err(Error::parse("expected delimiter after <<".to_string())), }; + let (delimiter, quoted, printed) = match self.current_raw.as_deref() { + Some(raw) => { + let (eof, quoted) = heredoc_eof_from_raw(raw); + let printed = if quoted { + single_quote(&eof) + } else { + raw.to_string() + }; + (eof, quoted, printed) + } + None => { + let printed = if quoted { + single_quote(&delimiter) + } else { + delimiter.clone() + }; + (delimiter, quoted, printed) + } + }; let (content, rest_of_line_chars) = self .lexer @@ -2518,11 +2658,12 @@ impl<'a> Parser<'a> { content }; - let target = if quoted { - Word::quoted_literal(content) + let mut target = if quoted { + Word::quoted_literal(content.clone()) } else { - self.parse_word(content) + self.parse_word(content.clone()) }; + target.raw = Some(content); let kind = if strip_tabs { RedirectKind::HereDocStrip @@ -2535,20 +2676,25 @@ impl<'a> Parser<'a> { fd_var: None, kind, target, + heredoc_delim: Some(printed), }); // Advance so re-injected rest-of-line tokens are picked up self.advance(); // Consume any trailing redirects on the same line (e.g. `cat < file`) - self.collect_trailing_redirects(redirects); - Ok(()) + self.collect_trailing_redirects(redirects) } /// Consume redirect tokens that follow a heredoc on the same line. - fn collect_trailing_redirects(&mut self, redirects: &mut Vec) { + fn collect_trailing_redirects(&mut self, redirects: &mut Vec) -> Result<()> { while let Some(tok) = &self.current_token { match tok { + // `cat < { + let strip_tabs = matches!(tok, tokens::Token::HereDocStrip); + return self.parse_heredoc_redirect(strip_tabs, redirects); + } tokens::Token::RedirectOut | tokens::Token::Clobber => { let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) { RedirectKind::Clobber @@ -2562,6 +2708,7 @@ impl<'a> Parser<'a> { fd_var: None, kind, target, + heredoc_delim: None, }); } } @@ -2573,6 +2720,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Append, target, + heredoc_delim: None, }); } } @@ -2585,6 +2733,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Output, target, + heredoc_delim: None, }); } } @@ -2596,6 +2745,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupOutput, target: Word::literal("-"), + heredoc_delim: None, }); } tokens::Token::DupInput => { @@ -2606,6 +2756,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupInput, target, + heredoc_delim: None, }); } } @@ -2618,6 +2769,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupInput, target: Word::literal(dst_fd.to_string()), + heredoc_delim: None, }); } tokens::Token::DupFdClose(fd) => { @@ -2628,6 +2780,7 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::DupInput, target: Word::literal("-"), + heredoc_delim: None, }); } tokens::Token::RedirectFdIn(fd) => { @@ -2639,12 +2792,14 @@ impl<'a> Parser<'a> { fd_var: None, kind: RedirectKind::Input, target, + heredoc_delim: None, }); } } _ => break, } } + Ok(()) } /// Extract fd-variable name from `{varname}` pattern in the last word. @@ -2680,137 +2835,15 @@ impl<'a> Parser<'a> { loop { match &self.current_token { - Some(tokens::Token::Word(w)) - | Some(tokens::Token::LiteralWord(w)) - | Some(tokens::Token::QuotedWord(w)) - | Some(tokens::Token::QuotedGlobWord(w)) => { - let is_literal = - matches!(&self.current_token, Some(tokens::Token::LiteralWord(_))); - let is_quoted = matches!( - &self.current_token, - Some(tokens::Token::QuotedWord(_)) | Some(tokens::Token::QuotedGlobWord(_)) - ); - let is_glob_quoted = - matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))); - // Clone early to release borrow on self.current_token - let w = w.clone(); - - // Stop if this word cannot start a command (like 'then', 'fi', etc.) - if words.is_empty() && Self::is_non_command_word(&w) { + Some( + tokens::Token::Word(_) + | tokens::Token::LiteralWord(_) + | tokens::Token::QuotedWord(_) + | tokens::Token::QuotedGlobWord(_), + ) => { + if !self.parse_simple_word(&mut words, &mut assignments) { break; } - - // Check for assignment (only before the command name, not for literal words) - if words.is_empty() - && !is_literal - && let Some((assignment, needs_advance)) = - self.try_parse_assignment(&w, is_glob_quoted) - { - if needs_advance { - self.advance(); - } - assignments.push(assignment); - continue; - } - - // Handle compound array assignment in arg position: - // declare -a arr=(x y z) → arr=(x y z) as single arg - if w.ends_with('=') && !words.is_empty() { - self.advance(); - if let Some(word) = self.try_parse_compound_array_arg(w.clone()) { - words.push(word); - continue; - } - // Not a compound assignment — treat as regular word - let word = if is_literal { - Word { - parts: vec![WordPart::Literal(w)], - quoted: true, - has_unquoted_glob: false, - part_quoted: Vec::new(), - } - } else { - let mut word = self.parse_word(w); - if is_quoted { - word.quoted = true; - } - if is_glob_quoted { - word.has_unquoted_glob = true; - } - word - }; - words.push(word); - continue; - } - - let word = if is_literal { - Word { - parts: vec![WordPart::Literal(w)], - quoted: true, - has_unquoted_glob: false, - part_quoted: Vec::new(), - } - } else { - let mut word = self.parse_word(w); - if is_quoted { - word.quoted = true; - } - if is_glob_quoted { - word.has_unquoted_glob = true; - } - word - }; - words.push(word); - self.advance(); - } - Some(tokens::Token::RedirectOut) | Some(tokens::Token::Clobber) => { - let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) { - RedirectKind::Clobber - } else { - RedirectKind::Output - }; - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: None, - fd_var, - kind, - target, - }); - } - Some(tokens::Token::RedirectAppend) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: None, - fd_var, - kind: RedirectKind::Append, - target, - }); - } - Some(tokens::Token::RedirectIn) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: None, - fd_var, - kind: RedirectKind::Input, - target, - }); - } - Some(tokens::Token::HereString) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: None, - fd_var, - kind: RedirectKind::HereString, - target, - }); } Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) => { let strip_tabs = @@ -2822,122 +2855,35 @@ impl<'a> Parser<'a> { let word = self.expect_word()?; words.push(word); } - Some(tokens::Token::RedirectBoth) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: None, - fd_var, - kind: RedirectKind::OutputBoth, - target, - }); - } - Some(tokens::Token::DupOutput) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: if fd_var.is_some() { None } else { Some(1) }, - fd_var, - kind: RedirectKind::DupOutput, - target, - }); - } - Some(tokens::Token::RedirectFd(fd)) => { - let fd = *fd; - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::Output, - target, - }); - } - Some(tokens::Token::RedirectFdAppend(fd)) => { - let fd = *fd; - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::Append, - target, - }); - } - Some(tokens::Token::DupFd(src_fd, dst_fd)) => { - let src_fd = *src_fd; - let dst_fd = *dst_fd; - self.advance(); - redirects.push(Redirect { - fd: Some(src_fd), - fd_var: None, - kind: RedirectKind::DupOutput, - target: Word::literal(dst_fd.to_string()), - }); - } - Some(tokens::Token::DupFdCloseOut(fd)) => { - let fd = *fd; - self.advance(); - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::DupOutput, - target: Word::literal("-"), - }); - } - Some(tokens::Token::DupInput) => { - let fd_var = Self::pop_fd_var(&mut words); - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: if fd_var.is_some() { None } else { Some(0) }, - fd_var, - kind: RedirectKind::DupInput, - target, - }); - } - Some(tokens::Token::DupFdIn(src_fd, dst_fd)) => { - let src_fd = *src_fd; - let dst_fd = *dst_fd; - self.advance(); - redirects.push(Redirect { - fd: Some(src_fd), - fd_var: None, - kind: RedirectKind::DupInput, - target: Word::literal(dst_fd.to_string()), - }); - } - Some(tokens::Token::DupFdClose(fd)) => { - let fd = *fd; - self.advance(); - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::DupInput, - target: Word::literal("-"), - }); - } - Some(tokens::Token::RedirectFdIn(fd)) => { - let fd = *fd; - self.advance(); - let target = self.expect_word()?; - redirects.push(Redirect { - fd: Some(fd), - fd_var: None, - kind: RedirectKind::Input, - target, - }); - } - // { and } as arguments (not in command position) are literal words - Some(tokens::Token::LeftBrace) | Some(tokens::Token::RightBrace) - if !words.is_empty() => - { - let sym = if matches!(self.current_token, Some(tokens::Token::LeftBrace)) { - "{" - } else { - "}" + Some( + tokens::Token::RedirectOut + | tokens::Token::Clobber + | tokens::Token::RedirectAppend + | tokens::Token::RedirectIn + | tokens::Token::HereString + | tokens::Token::RedirectBoth + | tokens::Token::DupOutput + | tokens::Token::RedirectFd(_) + | tokens::Token::RedirectFdAppend(_) + | tokens::Token::DupFd(..) + | tokens::Token::DupFdCloseOut(_) + | tokens::Token::DupInput + | tokens::Token::DupFdIn(..) + | tokens::Token::DupFdClose(_) + | tokens::Token::RedirectFdIn(_), + ) => { + self.parse_simple_redirect(&mut words, &mut redirects)?; + } + // {, } and ]] as arguments (not in command position) are literal words + Some( + tokens::Token::LeftBrace + | tokens::Token::RightBrace + | tokens::Token::DoubleRightBracket, + ) if !words.is_empty() => { + let sym = match self.current_token { + Some(tokens::Token::LeftBrace) => "{", + Some(tokens::Token::RightBrace) => "}", + _ => "]]", }; words.push(Word::literal(sym)); self.advance(); @@ -2981,11 +2927,285 @@ impl<'a> Parser<'a> { })) } + /// One word of a simple command (outlined to keep the recursive + /// `parse_simple_command` frame small; `$( )` nesting recurses through it). + /// Returns false when the word ends the command. + #[inline(never)] + fn parse_simple_word( + &mut self, + words: &mut Vec, + assignments: &mut Vec, + ) -> bool { + let (w, is_literal, is_quoted, is_glob_quoted) = match &self.current_token { + Some(tokens::Token::Word(w)) => (w.clone(), false, false, false), + Some(tokens::Token::LiteralWord(w)) => (w.clone(), true, false, false), + Some(tokens::Token::QuotedWord(w)) => (w.clone(), false, true, false), + Some(tokens::Token::QuotedGlobWord(w)) => (w.clone(), false, true, true), + _ => return false, + }; + // Stop if this word cannot start a command (like 'then', 'fi', etc.) + if words.is_empty() && Self::is_non_command_word(&w) { + return false; + } + + // Check for assignment (only before the command name, not for literal words) + if words.is_empty() + && !is_literal + && let Some((assignment, needs_advance)) = self.try_parse_assignment(&w, is_glob_quoted) + { + if needs_advance { + self.advance(); + } + assignments.push(assignment); + return true; + } + + // Handle compound array assignment in arg position: + // declare -a arr=(x y z) → arr=(x y z) as single arg + if w.ends_with('=') && !words.is_empty() { + let saved_raw = self.current_raw.clone(); + self.advance(); + if let Some(word) = self.try_parse_compound_array_arg(w.clone(), saved_raw.clone()) { + words.push(word); + return true; + } + // Not a compound assignment — treat as regular word + let word = if is_literal { + Word { + parts: vec![WordPart::Literal(w)], + quoted: true, + has_unquoted_glob: false, + part_quoted: Vec::new(), + raw: saved_raw.clone(), + } + } else { + let mut word = self.parse_word(w); + if is_quoted { + word.quoted = true; + } + if is_glob_quoted { + word.has_unquoted_glob = true; + } + word + }; + let mut word = word; + word.raw = saved_raw; + words.push(word); + return true; + } + + let word = if is_literal { + Word { + parts: vec![WordPart::Literal(w)], + quoted: true, + has_unquoted_glob: false, + part_quoted: Vec::new(), + raw: self.current_raw.clone(), + } + } else { + let mut word = self.parse_word(w); + if is_quoted { + word.quoted = true; + } + if is_glob_quoted { + word.has_unquoted_glob = true; + } + word + }; + words.push(self.with_raw(word)); + self.advance(); + true + } + + /// One redirection of a simple command (outlined, see `parse_simple_word`). + #[inline(never)] + fn parse_simple_redirect( + &mut self, + words: &mut Vec, + redirects: &mut Vec, + ) -> Result<()> { + match &self.current_token { + Some(tokens::Token::RedirectOut) | Some(tokens::Token::Clobber) => { + let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) { + RedirectKind::Clobber + } else { + RedirectKind::Output + }; + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: None, + fd_var, + kind, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectAppend) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: None, + fd_var, + kind: RedirectKind::Append, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectIn) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: None, + fd_var, + kind: RedirectKind::Input, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::HereString) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: None, + fd_var, + kind: RedirectKind::HereString, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectBoth) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: None, + fd_var, + kind: RedirectKind::OutputBoth, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::DupOutput) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: if fd_var.is_some() { None } else { Some(1) }, + fd_var, + kind: RedirectKind::DupOutput, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectFd(fd)) => { + let fd = *fd; + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: Some(fd), + fd_var: None, + kind: RedirectKind::Output, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectFdAppend(fd)) => { + let fd = *fd; + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: Some(fd), + fd_var: None, + kind: RedirectKind::Append, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::DupFd(src_fd, dst_fd)) => { + let src_fd = *src_fd; + let dst_fd = *dst_fd; + self.advance(); + redirects.push(Redirect { + fd: Some(src_fd), + fd_var: None, + kind: RedirectKind::DupOutput, + target: Word::literal(dst_fd.to_string()), + heredoc_delim: None, + }); + } + Some(tokens::Token::DupFdCloseOut(fd)) => { + let fd = *fd; + self.advance(); + redirects.push(Redirect { + fd: Some(fd), + fd_var: None, + kind: RedirectKind::DupOutput, + target: Word::literal("-"), + heredoc_delim: None, + }); + } + Some(tokens::Token::DupInput) => { + let fd_var = Self::pop_fd_var(words); + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: if fd_var.is_some() { None } else { Some(0) }, + fd_var, + kind: RedirectKind::DupInput, + target, + heredoc_delim: None, + }); + } + Some(tokens::Token::DupFdIn(src_fd, dst_fd)) => { + let src_fd = *src_fd; + let dst_fd = *dst_fd; + self.advance(); + redirects.push(Redirect { + fd: Some(src_fd), + fd_var: None, + kind: RedirectKind::DupInput, + target: Word::literal(dst_fd.to_string()), + heredoc_delim: None, + }); + } + Some(tokens::Token::DupFdClose(fd)) => { + let fd = *fd; + self.advance(); + redirects.push(Redirect { + fd: Some(fd), + fd_var: None, + kind: RedirectKind::DupInput, + target: Word::literal("-"), + heredoc_delim: None, + }); + } + Some(tokens::Token::RedirectFdIn(fd)) => { + let fd = *fd; + self.advance(); + let target = self.expect_word()?; + redirects.push(Redirect { + fd: Some(fd), + fd_var: None, + kind: RedirectKind::Input, + target, + heredoc_delim: None, + }); + } + _ => {} + } + Ok(()) + } + /// Expect a word token and return it as a Word fn expect_word(&mut self) -> Result { match &self.current_token { Some(tokens::Token::Word(w)) => { - let word = self.parse_word(w.clone()); + let word = self.with_raw(self.parse_word(w.clone())); self.advance(); Ok(word) } @@ -2996,13 +3216,14 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: self.current_raw.clone(), }; self.advance(); Ok(word) } Some(tokens::Token::QuotedWord(w)) | Some(tokens::Token::QuotedGlobWord(w)) => { // Double-quoted: parse for variable expansion - let word = self.parse_word(w.clone()); + let word = self.with_raw(self.parse_word(w.clone())); self.advance(); Ok(word) } @@ -3017,11 +3238,20 @@ impl<'a> Parser<'a> { let is_input = matches!(self.current_token, Some(tokens::Token::ProcessSubIn)); // Span end of the `<(` / `>(` token is exactly the start of the body. let body_start_offset = self.current_span.end.offset; + let mut depth = 1; + let mut body_end_offset = 0; + // Find the end the way `$(...)` does (quote/case/heredoc + // aware, see subst_scan), so `<(case a in a) ...;; esac)` + // does not stop at the pattern's `)`. + let scanned = self.peeked_token.is_none() && self.lexer.skip_subst_body(); + if scanned { + // The closing `)` is one byte, just consumed. + body_end_offset = self.lexer.position().offset.saturating_sub(1); + depth = 0; + } self.advance(); - let mut depth = 1; - let body_end_offset; - loop { + while depth > 0 { match &self.current_token { Some(tokens::Token::LeftParen) => { depth += 1; @@ -3094,9 +3324,11 @@ impl<'a> Parser<'a> { let result = inner_parser .parse_script_into(&mut commands) .map(|()| commands.into_iter().map(|(cmd, _)| cmd).collect::>()); - (result, inner_parser.fuel) + let raw = (self.function_depth > 0) + .then(|| format!("{}({cmd_src})", if is_input { '<' } else { '>' })); + (result, inner_parser.fuel, raw) }; - let (parse_result, remaining_fuel) = inner_result; + let (parse_result, remaining_fuel, raw) = inner_result; self.fuel = remaining_fuel; let commands = match parse_result { Ok(commands) => commands, @@ -3109,6 +3341,7 @@ impl<'a> Parser<'a> { quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), + raw, }) } _ => Err(self.error("expected word")), @@ -3139,6 +3372,7 @@ impl<'a> Parser<'a> { quoted: true, has_unquoted_glob: false, part_quoted: Vec::new(), + raw: None, }), _ => None, } @@ -3795,6 +4029,17 @@ impl<'a> Parser<'a> { } if ch == '"' { in_dq = !in_dq; + } else if ch == '\'' && !in_dq { + // `"${y/b/'}'}"`: a single-quoted span + // hides `}`; the expansion quote-removes it. + repl.push(chars.next().unwrap()); + for q in chars.by_ref() { + repl.push(q); + if q == '\'' { + break; + } + } + continue; } else if ch == '\\' { repl.push(chars.next().unwrap()); if let Some(n) = chars.next() { @@ -3951,6 +4196,7 @@ impl<'a> Parser<'a> { quoted: false, has_unquoted_glob: false, part_quoted, + raw: None, } } @@ -3965,6 +4211,19 @@ impl<'a> Parser<'a> { if let Some(n) = chars.next() { operand.push(n); } + } else if c == '$' { + operand.push(chars.next().unwrap()); + if chars.peek() == Some(&'(') { + // `${x:-$(echo })}`: the substitution owns its braces. + operand.push(chars.next().unwrap()); + let mut scanner = subst_scan::SubstScanner::new(); + for n in chars.by_ref() { + operand.push(n); + if scanner.feed(n) == subst_scan::Step::Close { + break; + } + } + } } else if c == '{' { depth += 1; operand.push(chars.next().unwrap()); @@ -3983,6 +4242,24 @@ impl<'a> Parser<'a> { } } +/// `a |& b` is `a 2>&1 | b`: append the dup to `a`. Out of line to keep +/// the recursive `parse_pipeline` frame small (TM-DOS-044). +#[inline(never)] +fn add_stderr_to_pipe(cmd: &mut Command) { + let dup = Redirect { + fd: Some(2), + fd_var: None, + kind: RedirectKind::DupOutput, + target: Word::literal("1"), + heredoc_delim: None, + }; + match cmd { + Command::Simple(sc) => sc.redirects.push(dup), + Command::Compound(_, redirects) => redirects.push(dup), + _ => {} + } +} + /// `cmd &` right before a closing keyword: an empty command carrying the `&`. /// Out of line so the `Command` temporary does not enlarge the recursive /// `parse_command_list` frame (nested `$(...)` parse depth, TM-DOS-044). @@ -4102,6 +4379,34 @@ pub(crate) fn unescape_glob_literal(s: &str) -> String { out } +/// Split the text of `for ((init; cond; step))` at top-level `;`. +fn split_arith_for_parts(body: &str) -> Vec { + let mut parts = Vec::new(); + let mut cur = String::new(); + let mut depth = 0usize; + for c in body.chars() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ';' if depth == 0 => { + parts.push(std::mem::take(&mut cur)); + continue; + } + _ => {} + } + cur.push(c); + } + parts.push(cur); + parts +} + +/// Expression text an arithmetic command evaluates: its source with +/// surrounding blanks trimmed and double quotes removed (bash treats +/// `(( ... ))` like `let "..."`). +pub(crate) fn arith_exec_text(raw: &str) -> String { + raw.trim().chars().filter(|&c| c != '"').collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/bashkit/src/parser/print_cmd.rs b/crates/bashkit/src/parser/print_cmd.rs new file mode 100644 index 000000000..417e7f6ca --- /dev/null +++ b/crates/bashkit/src/parser/print_cmd.rs @@ -0,0 +1,941 @@ +//! Function body printer for `type` and `declare -f`. +//! +//! Important decision: a port of bash's `print_cmd.c` layout rules +//! (4-space indent, `;` + newline between commands, `if`/`elif` printed as +//! nested `else if`, deferred here-document bodies, `was_heredoc` quirks) +//! over bashkit's AST. Words print as written (`Word::raw`, recorded by the +//! parser inside function definitions); a word without source text falls +//! back to a reconstruction from its parts. Output never uses `Debug` +//! (TM-INF-022). + +use super::ast::*; +use super::raw::heredoc_eof_from_raw; + +const INDENT_AMOUNT: usize = 4; + +/// Bash's text for `type NAME` / `declare -f NAME`: `name () \n{ \n...\n}`. +pub fn function_string(name: &str, body: &Command) -> String { + let mut p = Printer::default(); + if is_reserved_word(name) { + p.out.push_str("function "); + } + p.out.push_str(name); + p.out.push_str(" () \n"); + p.inside_function_def += 1; + p.out.push_str("{ \n"); + p.indentation += INDENT_AMOUNT; + let func_redirects = p.print_function_body(body); + p.print_deferred_if_any(""); + p.indentation -= INDENT_AMOUNT; + p.inside_function_def -= 1; + p.close_function(func_redirects); + p.out +} + +fn is_reserved_word(name: &str) -> bool { + matches!( + name, + "if" | "then" + | "else" + | "elif" + | "fi" + | "case" + | "esac" + | "for" + | "select" + | "while" + | "until" + | "do" + | "done" + | "in" + | "function" + | "time" + | "{" + | "}" + | "!" + | "[[" + | "]]" + | "coproc" + ) +} + +#[derive(Clone, Copy, PartialEq)] +enum Conn { + Semi, + Amp, + And, + Or, + Pipe, +} + +/// A here-document whose body prints after the current line. +struct HereDoc { + body: String, + eof: String, +} + +#[derive(Default)] +struct Printer { + out: String, + indentation: usize, + skip_this_indent: usize, + was_heredoc: bool, + printing_connection: usize, + inside_function_def: usize, + deferred: Vec, +} + +impl Printer { + fn indent(&mut self, n: usize) { + for _ in 0..n { + self.out.push(' '); + } + } + + /// `newline (s)`: newline, indent, then `s`. + fn newline(&mut self, s: &str) { + self.out.push('\n'); + self.indent(self.indentation); + self.out.push_str(s); + } + + /// `semicolon ()`: a `;` unless the text already ends a line or a job. + fn semicolon(&mut self) { + if matches!(self.out.chars().last(), Some('&' | '\n')) { + return; + } + self.out.push(';'); + } + + /// Prints a function body; returns the redirections of a `{ }` body, + /// which print after its closing brace. + fn print_function_body<'a>(&mut self, body: &'a Command) -> &'a [Redirect] { + match body { + Command::Compound(CompoundCommand::BraceGroup(cmds), redirects) => { + self.print_list(cmds); + redirects + } + other => { + self.make(Some(other)); + &[] + } + } + } + + fn close_function(&mut self, func_redirects: &[Redirect]) { + if func_redirects.is_empty() { + self.newline("}"); + } else { + self.newline("} "); + self.print_redirection_list(func_redirects); + } + } + + fn print_deferred_if_any(&mut self, cstring: &str) { + if !self.deferred.is_empty() { + self.print_deferred_heredocs(cstring); + } + } + + fn print_deferred_heredocs(&mut self, cstring: &str) { + let prints_cstring = !cstring.is_empty() && cstring != ";"; + if prints_cstring { + self.out.push_str(cstring); + } + if !self.deferred.is_empty() { + let docs = std::mem::take(&mut self.deferred); + self.print_heredoc_bodies(&docs); + if prints_cstring { + self.out.push(' '); + } + self.was_heredoc = true; + } + } + + fn print_heredoc_bodies(&mut self, docs: &[HereDoc]) { + self.out.push('\n'); + for doc in docs { + self.out.push_str(&doc.body); + self.out.push_str(&doc.eof); + self.out.push('\n'); + } + } + + /// A command sequence (`;`/newline separated). + fn print_list(&mut self, cmds: &[Command]) { + match cmds { + [] => self.make(None), + [only] => self.make(Some(only)), + [first, rest @ ..] => { + let rest: Vec<(Conn, Option<&Command>)> = + rest.iter().map(|c| (Conn::Semi, Some(c))).collect(); + self.make_connection(first, &rest); + } + } + } + + /// `make_command_string_internal`. + fn make(&mut self, cmd: Option<&Command>) { + let Some(cmd) = cmd else { + return; + }; + if self.skip_this_indent > 0 { + self.skip_this_indent -= 1; + } else { + self.indent(self.indentation); + } + match cmd { + Command::Simple(sc) => self.print_simple(sc), + Command::Pipeline(p) => { + if p.negated { + self.out.push_str("! "); + } + match p.commands.as_slice() { + [] => {} + [only] => { + self.skip_this_indent += 1; + self.make(Some(only)); + } + [first, rest @ ..] => { + let rest: Vec<(Conn, Option<&Command>)> = + rest.iter().map(|c| (Conn::Pipe, Some(c))).collect(); + self.connection_body(first, &rest); + } + } + } + Command::List(list) => { + let rest: Vec<(Conn, Option<&Command>)> = list + .rest + .iter() + .map(|(op, c)| { + let conn = match op { + ListOperator::And => Conn::And, + ListOperator::Or => Conn::Or, + ListOperator::Semicolon => Conn::Semi, + ListOperator::Background => Conn::Amp, + }; + let second = if is_empty_placeholder(c) { + None + } else { + Some(c) + }; + (conn, second) + }) + .collect(); + self.connection_body(&list.first, &rest); + } + Command::Compound(cc, redirects) => { + self.print_compound(cc); + if !redirects.is_empty() { + self.out.push(' '); + self.print_redirection_list(redirects); + } + } + Command::Function(f) => self.print_function_def(f), + } + } + + /// A connection chain, as an indented command of its own. + fn make_connection(&mut self, first: &Command, rest: &[(Conn, Option<&Command>)]) { + if self.skip_this_indent > 0 { + self.skip_this_indent -= 1; + } else { + self.indent(self.indentation); + } + self.connection_body(first, rest); + } + + /// The `cm_connection` case, flattened over a left-associative chain: + /// each link ends like bash's nested connection (deferred heredocs). + fn connection_body(&mut self, first: &Command, rest: &[(Conn, Option<&Command>)]) { + self.skip_this_indent += 1; + self.printing_connection += 1; + self.make(Some(first)); + for (conn, second) in rest { + match conn { + Conn::Amp | Conn::Pipe => { + let s = if *conn == Conn::Amp { " &" } else { " |" }; + self.print_deferred_heredocs(s); + if *conn != Conn::Amp || second.is_some() { + self.out.push(' '); + self.skip_this_indent += 1; + } + } + Conn::And | Conn::Or => { + let s = if *conn == Conn::And { " && " } else { " || " }; + self.print_deferred_heredocs(s); + if second.is_some() { + self.skip_this_indent += 1; + } + } + Conn::Semi => { + if self.deferred.is_empty() { + if !self.was_heredoc { + self.out.push(';'); + } else { + self.was_heredoc = false; + } + } else { + let s = if self.inside_function_def > 0 { + "" + } else { + ";" + }; + self.print_deferred_heredocs(s); + } + if self.inside_function_def > 0 { + self.out.push('\n'); + } else { + self.out.push(' '); + if second.is_some() { + self.skip_this_indent += 1; + } + } + } + } + self.make(*second); + self.print_deferred_if_any(""); + } + self.printing_connection -= 1; + } + + fn print_simple(&mut self, sc: &SimpleCommand) { + let mut words: Vec = Vec::new(); + for a in &sc.assignments { + words.push(assignment_text(a)); + } + let name = word_text(&sc.name); + if !(name.is_empty() && sc.name.raw.is_none()) { + words.push(name); + } + words.extend(sc.args.iter().map(word_text)); + self.out.push_str(&words.join(" ")); + if !sc.redirects.is_empty() { + if !words.is_empty() { + self.out.push(' '); + } + self.print_redirection_list(&sc.redirects); + } + } + + fn print_redirection_list(&mut self, redirects: &[Redirect]) { + let mut heredocs = Vec::new(); + self.was_heredoc = false; + for (i, r) in redirects.iter().enumerate() { + if i > 0 { + self.out.push(' '); + } + if matches!(r.kind, RedirectKind::HereDoc | RedirectKind::HereDocStrip) { + let delim = r.heredoc_delim.clone().unwrap_or_default(); + self.out.push_str(&redirector(r, 0)); + self.out.push_str(if r.kind == RedirectKind::HereDocStrip { + "<<-" + } else { + "<<" + }); + self.out.push_str(&delim); + let eof = if delim.starts_with('\'') { + heredoc_eof_from_raw(&delim).0 + } else { + delim + }; + heredocs.push(HereDoc { + body: r + .target + .raw + .clone() + .unwrap_or_else(|| reconstruct_word(&r.target)), + eof, + }); + } else { + self.out.push_str(&redirection_text(r)); + } + } + if heredocs.is_empty() { + return; + } + if self.printing_connection > 0 { + self.deferred.extend(heredocs); + } else { + self.print_heredoc_bodies(&heredocs); + } + } + + fn print_compound(&mut self, cc: &CompoundCommand) { + match cc { + CompoundCommand::If(c) => self.print_if( + &c.condition, + &c.then_branch, + &c.elif_branches, + c.else_branch.as_deref(), + ), + CompoundCommand::For(f) => { + self.out.push_str("for "); + self.out.push_str(&f.variable); + self.out.push_str(" in "); + match &f.words { + Some(words) => { + let w: Vec = words.iter().map(word_text).collect(); + self.out.push_str(&w.join(" ")); + } + None => self.out.push_str("\"$@\""), + } + self.loop_body_after_head(&f.body); + } + CompoundCommand::Select(f) => { + self.out.push_str("select "); + self.out.push_str(&f.variable); + self.out.push_str(" in "); + let w: Vec = f.words.iter().map(word_text).collect(); + self.out.push_str(&w.join(" ")); + self.loop_body_after_head(&f.body); + } + CompoundCommand::ArithmeticFor(f) => { + let parts: Vec = match &f.raw { + Some(raw) => raw.iter().map(|p| arith_for_part(p)).collect(), + None => [&f.init, &f.condition, &f.step] + .iter() + .map(|p| arith_for_part(p)) + .collect(), + }; + self.out.push_str("for (("); + self.out.push_str(&parts.join("; ")); + self.out.push_str("))"); + self.newline("do\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(&f.body); + self.print_deferred_if_any(""); + self.semicolon(); + self.indentation -= INDENT_AMOUNT; + self.newline("done"); + } + CompoundCommand::While(w) => self.print_until_or_while("while", &w.condition, &w.body), + CompoundCommand::Until(w) => self.print_until_or_while("until", &w.condition, &w.body), + CompoundCommand::Case(c) => { + self.out.push_str("case "); + self.out.push_str(&word_text(&c.word)); + self.out.push_str(" in "); + self.indentation += INDENT_AMOUNT; + for item in &c.cases { + self.newline(""); + let pats: Vec = item.patterns.iter().map(word_text).collect(); + self.out.push_str(&pats.join(" | ")); + self.out.push_str(")\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(&item.commands); + self.indentation -= INDENT_AMOUNT; + self.print_deferred_if_any(""); + self.newline(match item.terminator { + CaseTerminator::Break => ";;", + CaseTerminator::FallThrough => ";&", + CaseTerminator::Continue => ";;&", + }); + } + self.indentation -= INDENT_AMOUNT; + self.newline("esac"); + } + CompoundCommand::Subshell(cmds) => { + self.out.push_str("( "); + self.skip_this_indent += 1; + self.print_list(cmds); + self.print_deferred_if_any(""); + self.out.push_str(" )"); + } + CompoundCommand::BraceGroup(cmds) => { + self.out.push_str("{ "); + if self.inside_function_def == 0 { + self.skip_this_indent += 1; + } else { + self.out.push('\n'); + self.indentation += INDENT_AMOUNT; + } + self.print_list(cmds); + self.print_deferred_if_any(""); + if self.inside_function_def > 0 { + self.out.push('\n'); + self.indentation -= INDENT_AMOUNT; + self.indent(self.indentation); + } else { + self.semicolon(); + self.out.push(' '); + } + self.out.push('}'); + } + CompoundCommand::Arithmetic(expr) => { + self.out.push_str("(("); + self.out.push_str(expr); + self.out.push_str("))"); + } + CompoundCommand::Time(t) => { + self.out.push_str("time"); + if t.posix_format { + self.out.push_str(" -p"); + } + if let Some(cmd) = &t.command { + self.out.push(' '); + self.skip_this_indent += 1; + self.make(Some(cmd)); + } + } + CompoundCommand::Conditional(words) => { + self.out.push_str("[[ "); + let toks: Vec = words.iter().map(word_text).collect(); + self.out.push_str(&cond_text(&toks)); + self.out.push_str(" ]]"); + } + CompoundCommand::Coproc(c) => { + self.out.push_str("coproc "); + self.out.push_str(&c.name); + self.out.push(' '); + self.skip_this_indent += 1; + self.make(Some(&c.body)); + } + } + } + + fn loop_body_after_head(&mut self, body: &[Command]) { + self.out.push(';'); + self.newline("do\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(body); + self.print_deferred_if_any(""); + self.semicolon(); + self.indentation -= INDENT_AMOUNT; + self.newline("done"); + } + + fn print_until_or_while(&mut self, which: &str, test: &[Command], body: &[Command]) { + self.out.push_str(which); + self.out.push(' '); + self.skip_this_indent += 1; + self.print_list(test); + self.print_deferred_if_any(""); + self.semicolon(); + self.out.push_str(" do\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(body); + self.print_deferred_if_any(""); + self.indentation -= INDENT_AMOUNT; + self.semicolon(); + self.newline("done"); + } + + /// `if`; bash stores `elif` as a nested `if` in the `else` branch. + fn print_if( + &mut self, + test: &[Command], + then: &[Command], + elifs: &[(Vec, Vec)], + else_branch: Option<&[Command]>, + ) { + self.out.push_str("if "); + self.skip_this_indent += 1; + self.print_list(test); + self.semicolon(); + self.out.push_str(" then\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(then); + self.print_deferred_if_any(""); + self.indentation -= INDENT_AMOUNT; + if let Some(((etest, ethen), rest)) = elifs.split_first() { + self.semicolon(); + self.newline("else\n"); + self.indentation += INDENT_AMOUNT; + self.indent(self.indentation); + self.print_if(etest, ethen, rest, else_branch); + self.print_deferred_if_any(""); + self.indentation -= INDENT_AMOUNT; + } else if let Some(else_cmds) = else_branch { + self.semicolon(); + self.newline("else\n"); + self.indentation += INDENT_AMOUNT; + self.print_list(else_cmds); + self.print_deferred_if_any(""); + self.indentation -= INDENT_AMOUNT; + } + self.semicolon(); + self.newline("fi"); + } + + /// Nested `function name () { ... }` inside a body. + fn print_function_def(&mut self, f: &FunctionDef) { + self.out.push_str("function "); + self.out.push_str(&f.name); + self.out.push_str(" () \n"); + self.indent(self.indentation); + self.out.push_str("{ \n"); + self.inside_function_def += 1; + self.indentation += INDENT_AMOUNT; + let func_redirects = self.print_function_body(&f.body); + self.print_deferred_if_any(""); + self.indentation -= INDENT_AMOUNT; + self.inside_function_def -= 1; + self.close_function(func_redirects); + } +} + +/// The `cmd &` placeholder the parser appends after a trailing `&`. +fn is_empty_placeholder(cmd: &Command) -> bool { + matches!(cmd, Command::Simple(sc) + if sc.assignments.is_empty() + && sc.args.is_empty() + && sc.redirects.is_empty() + && sc.name.raw.is_none() + && sc.name.parts.iter().all(|p| matches!(p, WordPart::Literal(s) if s.is_empty()))) +} + +/// One clause of `for ((...))`: leading blanks dropped, empty means `1`. +fn arith_for_part(p: &str) -> String { + let t = p.trim_start(); + if t.trim().is_empty() { + "1".to_string() + } else { + t.to_string() + } +} + +fn redirector(r: &Redirect, default_fd: i32) -> String { + if let Some(var) = &r.fd_var { + return format!("{{{var}}}"); + } + match r.fd { + Some(fd) if fd != default_fd => fd.to_string(), + _ => String::new(), + } +} + +fn redirection_text(r: &Redirect) -> String { + let target = word_text(&r.target); + match r.kind { + RedirectKind::Output => format!("{}> {target}", redirector(r, 1)), + RedirectKind::Clobber => format!("{}>| {target}", redirector(r, 1)), + RedirectKind::Append => format!("{}>> {target}", redirector(r, 1)), + RedirectKind::Input => format!("{}< {target}", redirector(r, 0)), + RedirectKind::HereString => format!("{}<<< {target}", redirector(r, 0)), + RedirectKind::OutputBoth => format!("&> {target}"), + RedirectKind::DupOutput => { + let fd_is_default = r.fd_var.is_none() && r.fd.is_none_or(|fd| fd == 1); + let word_target = !target.starts_with(|c: char| c == '-' || c.is_ascii_digit()) + && !target.starts_with('$'); + if fd_is_default && word_target { + format!("&> {target}") + } else { + format!("{}>&{target}", redirector(r, 1)) + } + } + RedirectKind::DupInput => format!("{}<&{target}", redirector(r, 0)), + RedirectKind::HereDoc | RedirectKind::HereDocStrip => String::new(), + } +} + +fn assignment_text(a: &Assignment) -> String { + let mut s = a.name.clone(); + if let Some(idx) = &a.index { + s.push('['); + s.push_str(idx); + s.push(']'); + } + s.push_str(if a.append { "+=" } else { "=" }); + match &a.value { + AssignmentValue::Scalar(w) => s.push_str(&word_text(w)), + AssignmentValue::Array(elems) => { + let e: Vec = elems.iter().map(word_text).collect(); + s.push('('); + s.push_str(&e.join(" ")); + s.push(')'); + } + } + s +} + +/// A word as written: its source text without line continuations, or a +/// reconstruction when the parser did not record the source. +fn word_text(w: &Word) -> String { + match &w.raw { + Some(raw) => strip_line_continuations(raw), + None => reconstruct_word(w), + } +} + +/// Bash's lexer drops backslash-newline outside single quotes. +fn strip_line_continuations(raw: &str) -> String { + if !raw.contains("\\\n") { + return raw.to_string(); + } + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars().peekable(); + let mut in_single = false; + let mut in_double = false; + while let Some(c) = chars.next() { + match c { + '\'' if !in_double => { + in_single = !in_single; + out.push(c); + } + '"' if !in_single => { + in_double = !in_double; + out.push(c); + } + '\\' if !in_single => { + if chars.peek() == Some(&'\n') { + chars.next(); + } else { + out.push(c); + if let Some(n) = chars.next() { + out.push(n); + } + } + } + _ => out.push(c), + } + } + out +} + +fn reconstruct_word(w: &Word) -> String { + let mut s = String::new(); + for part in &w.parts { + match part { + WordPart::Literal(t) => s.push_str(t), + WordPart::CommandSubstitution(cmds) => { + s.push_str("$("); + s.push_str(&inline_commands(cmds)); + s.push(')'); + } + WordPart::ProcessSubstitution { commands, is_input } => { + s.push_str(if *is_input { "<(" } else { ">(" }); + s.push_str(&inline_commands(commands)); + s.push(')'); + } + other => { + let single = Word { + parts: vec![other.clone()], + quoted: false, + has_unquoted_glob: false, + part_quoted: Vec::new(), + raw: None, + }; + s.push_str(&single.to_string()); + } + } + } + if w.quoted && w.parts.iter().all(|p| matches!(p, WordPart::Literal(_))) { + return super::raw::single_quote(&s); + } + s +} + +/// Commands printed on one line (inside `$(...)` reconstructions). +fn inline_commands(cmds: &[Command]) -> String { + let mut p = Printer::default(); + p.print_list(cmds); + p.print_deferred_if_any(""); + p.out +} + +/// `[[ ... ]]` operands, as bash's `print_cond_node` prints its parse: +/// a lone operand becomes `-n WORD`. +fn cond_text(toks: &[String]) -> String { + let mut pos = 0; + let mut out = Vec::new(); + cond_or(toks, &mut pos, &mut out); + // Anything the grammar did not consume prints as written. + out.extend(toks[pos.min(toks.len())..].iter().cloned()); + out.join(" ") +} + +fn cond_or(toks: &[String], pos: &mut usize, out: &mut Vec) { + cond_and(toks, pos, out); + while toks.get(*pos).map(String::as_str) == Some("||") { + out.push("||".into()); + *pos += 1; + cond_and(toks, pos, out); + } +} + +fn cond_and(toks: &[String], pos: &mut usize, out: &mut Vec) { + cond_term(toks, pos, out); + while toks.get(*pos).map(String::as_str) == Some("&&") { + out.push("&&".into()); + *pos += 1; + cond_term(toks, pos, out); + } +} + +fn is_cond_unary(op: &str) -> bool { + matches!( + op, + "-a" | "-b" + | "-c" + | "-d" + | "-e" + | "-f" + | "-g" + | "-h" + | "-k" + | "-p" + | "-r" + | "-s" + | "-t" + | "-u" + | "-w" + | "-x" + | "-O" + | "-G" + | "-L" + | "-S" + | "-N" + | "-n" + | "-z" + | "-o" + | "-v" + | "-R" + ) +} + +fn is_cond_binary(op: &str) -> bool { + matches!( + op, + "==" | "=" + | "!=" + | "<" + | ">" + | "=~" + | "-eq" + | "-ne" + | "-lt" + | "-le" + | "-gt" + | "-ge" + | "-nt" + | "-ot" + | "-ef" + ) +} + +fn cond_term(toks: &[String], pos: &mut usize, out: &mut Vec) { + let Some(tok) = toks.get(*pos) else { + return; + }; + match tok.as_str() { + "!" => { + out.push("!".into()); + *pos += 1; + cond_term(toks, pos, out); + } + "(" => { + out.push("(".into()); + *pos += 1; + cond_or(toks, pos, out); + if toks.get(*pos).map(String::as_str) == Some(")") { + *pos += 1; + } + out.push(")".into()); + } + _ => { + let next = toks.get(*pos + 1).map(String::as_str); + if next.is_some_and(is_cond_binary) { + out.push(tok.clone()); + out.push(toks[*pos + 1].clone()); + if let Some(rhs) = toks.get(*pos + 2) { + out.push(rhs.clone()); + } + *pos += 3; + } else if is_cond_unary(tok) && next.is_some_and(|n| n != "&&" && n != "||" && n != ")") + { + out.push(tok.clone()); + out.push(toks[*pos + 1].clone()); + *pos += 2; + } else { + out.push("-n".into()); + out.push(tok.clone()); + *pos += 1; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::Parser; + + fn print_fn(src: &str) -> String { + let script = Parser::new(src).parse().expect("parses"); + let Some(Command::Function(f)) = script.commands.first() else { + panic!("expected a function definition"); + }; + function_string(&f.name, &f.body) + } + + #[test] + fn simple_body_keeps_words_as_written() { + assert_eq!( + print_fn("f() { echo 'a b' \"$x\" \\$y >out; }"), + "f () \n{ \n echo 'a b' \"$x\" \\$y > out\n}" + ); + } + + #[test] + fn elif_prints_as_nested_else_if() { + assert_eq!( + print_fn("f() { if a; then b; elif c; then d; fi; }"), + "f () \n{ \n if a; then\n b;\n else\n if c; then\n d;\n fi;\n fi\n}" + ); + } + + #[test] + fn heredoc_body_follows_its_line() { + assert_eq!( + print_fn("f() { cat <<'E'\n$x\nE\necho z; }"), + "f () \n{ \n cat <<'E'\n$x\nE\n\n echo z\n}" + ); + } + + #[test] + fn lone_cond_operand_gets_dash_n() { + assert_eq!( + print_fn("f() { [[ x ]]; [[ ! ( a == b ) ]]; }"), + "f () \n{ \n [[ -n x ]];\n [[ ! ( a == b ) ]]\n}" + ); + } + + #[test] + fn pipe_both_prints_as_dup_redirect() { + assert_eq!(print_fn("f() { a |& b; }"), "f () \n{ \n a 2>&1 | b\n}"); + } + + #[test] + fn reserved_word_name_gets_function_keyword() { + let body = Parser::new("{ :; }").parse().expect("parses").commands[0].clone(); + assert!(function_string("if", &body).starts_with("function if () \n")); + } + + #[test] + fn word_without_source_text_is_reconstructed() { + assert_eq!(word_text(&Word::literal("plain")), "plain"); + assert_eq!(word_text(&Word::quoted_literal("a b")), "'a b'"); + } + + #[test] + fn line_continuation_dropped_outside_single_quotes() { + assert_eq!(strip_line_continuations("a\\\nb"), "ab"); + assert_eq!(strip_line_continuations("'a\\\nb'"), "'a\\\nb'"); + } + + #[test] + fn empty_arith_for_clause_reads_one() { + assert_eq!(arith_for_part(" "), "1"); + assert_eq!(arith_for_part(" i < 3 "), "i < 3 "); + } + + #[test] + fn top_level_words_do_not_keep_source_text() { + // Only function bodies pay for recorded source text. + let script = Parser::new("echo 'a'").parse().expect("parses"); + let Some(Command::Simple(sc)) = script.commands.first() else { + panic!("expected a simple command"); + }; + assert!(sc.args[0].raw.is_none()); + } +} diff --git a/crates/bashkit/src/parser/raw.rs b/crates/bashkit/src/parser/raw.rs new file mode 100644 index 000000000..7f760d0c5 --- /dev/null +++ b/crates/bashkit/src/parser/raw.rs @@ -0,0 +1,151 @@ +//! Helpers for words kept as written in the source. +//! +//! Important decision: bash prints function bodies (`type`, `declare -f`) +//! with each word exactly as written, so the parser records word source text +//! inside function definitions (`Word::raw`). These helpers derive the +//! source-level pieces the parser cannot get from decoded tokens. + +/// Quote-remove a here-document delimiter as written; the bool reports +/// whether any part of it was quoted (bash then reads the body literally). +pub(crate) fn heredoc_eof_from_raw(raw: &str) -> (String, bool) { + let mut eof = String::new(); + let mut quoted = false; + let mut chars = raw.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' => { + quoted = true; + if let Some(n) = chars.next() { + eof.push(n); + } + } + '\'' => { + quoted = true; + for n in chars.by_ref() { + if n == '\'' { + break; + } + eof.push(n); + } + } + '"' => { + quoted = true; + while let Some(n) = chars.next() { + match n { + '"' => break, + '\\' if matches!(chars.peek(), Some('"' | '\\' | '$' | '`')) => { + if let Some(e) = chars.next() { + eof.push(e); + } + } + _ => eof.push(n), + } + } + } + _ => eof.push(c), + } + } + (eof, quoted) +} + +/// Single-quote `s` for shell output (`'it'\''s'`). +pub(crate) fn single_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Split source text on unquoted blanks, keeping quotes, escapes and +/// `$(..)`/`${..}` groups intact (compound array elements as written). +/// Backslash-newline continuations are dropped, as the lexer does. +pub(crate) fn split_raw_words(s: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut depth = 0usize; + let mut chars = s.chars(); + while let Some(c) = chars.next() { + match c { + '\\' => match chars.next() { + Some('\n') => {} + Some(n) => { + cur.push(c); + cur.push(n); + } + None => cur.push(c), + }, + '\'' => { + cur.push(c); + for n in chars.by_ref() { + cur.push(n); + if n == '\'' { + break; + } + } + } + '"' => { + cur.push(c); + while let Some(n) = chars.next() { + cur.push(n); + if n == '\\' { + if let Some(e) = chars.next() { + cur.push(e); + } + } else if n == '"' { + break; + } + } + } + '(' | '{' => { + depth += 1; + cur.push(c); + } + ')' | '}' => { + depth = depth.saturating_sub(1); + cur.push(c); + } + ' ' | '\t' | '\n' if depth == 0 => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + } + _ => cur.push(c), + } + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heredoc_delimiter_quote_removal() { + assert_eq!(heredoc_eof_from_raw("EOF"), ("EOF".into(), false)); + assert_eq!(heredoc_eof_from_raw("'EOF'"), ("EOF".into(), true)); + assert_eq!(heredoc_eof_from_raw("\\E\\OF"), ("EOF".into(), true)); + assert_eq!(heredoc_eof_from_raw("E\"OF\""), ("EOF".into(), true)); + } + + #[test] + fn single_quote_escapes_quotes() { + assert_eq!(single_quote("it's"), "'it'\\''s'"); + } + + #[test] + fn split_raw_words_keeps_quotes_and_groups() { + assert_eq!( + split_raw_words(" 1 2\n 3 "), + vec!["1".to_string(), "2".into(), "3".into()] + ); + assert_eq!( + split_raw_words("'a b' \"c d\" $(x y) e\\ f"), + vec![ + "'a b'".to_string(), + "\"c d\"".into(), + "$(x y)".into(), + "e\\ f".into() + ] + ); + } +} diff --git a/crates/bashkit/src/parser/tokens.rs b/crates/bashkit/src/parser/tokens.rs index 81ad385b2..258961478 100644 --- a/crates/bashkit/src/parser/tokens.rs +++ b/crates/bashkit/src/parser/tokens.rs @@ -42,6 +42,9 @@ pub enum Token { /// Pipe (|) Pipe, + /// Pipe with stderr (|&), shorthand for `2>&1 |` + PipeBoth, + /// And (&&) And, diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh index ec666839c..a9c580fa1 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh @@ -52,7 +52,6 @@ a) b) ) ### bashbox_compound_syntax_a_brace_inside_inside # a brace inside $(...) inside ${...} -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo ${x:-$(echo })} ### expect } @@ -60,7 +59,6 @@ echo ${x:-$(echo })} ### bashbox_compound_syntax_quoted_and_escaped_braces_inside # quoted and escaped braces inside ${...} -### skip: TODO bashbox corpus gap, bashkit output differs from real bash y=ab; echo ${x:-"}"} ${x:-\}} ${x:-'a}'} ${y/a/\}} "${y/b/'}'}" ### expect } } a} }b a} diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh index 35f532161..e4b787eaa 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh @@ -97,7 +97,6 @@ echo !x ### bashbox_lexer_double_brackets_followed_by_text_are_a_word # double brackets followed by text are a word -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo ]]x ### expect ]]x @@ -119,7 +118,6 @@ yes ### bashbox_lexer_is_a_shift_inside # << is a shift inside (( )) -### skip: TODO bashbox corpus gap, bashkit output differs from real bash (( x = 1 << 2 )) echo $x ### expect @@ -188,7 +186,6 @@ $x ### bashbox_lexer_backslash_escaped # backslash escaped -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=1; cat <<\E\OF $x EOF @@ -198,7 +195,6 @@ $x ### bashbox_lexer_partly_quoted # partly quoted -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=1; cat < /tmp/o; cat /tmp/o ### bashbox_parser_c_style_for_keeps_spacing_of_each_clause # C-style for keeps spacing of each clause -### skip: TODO bashbox corpus gap, bashkit output differs from real bash for ((i = 3; i > 0; i = i - -1 - 2)); do echo $i; done 2>/dev/null ### expect 3 @@ -163,7 +162,6 @@ ab ### bashbox_parser_braces_brackets_and_bang_are_words_outside_command_position # braces, brackets and bang are words outside command position -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo } ]] ! ### expect } ]] ! diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-script-file.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-script-file.test.sh index eb691f26e..d444cd2cb 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-script-file.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-script-file.test.sh @@ -33,7 +33,6 @@ X=2; export Y=3; printf 'echo "$0 $# $1 [$X][$Y][$FOO]"; exit 3' > s; chmod +x s ### bashbox_script_file_the_script_s_changes_stay_in_the_child # the script's changes stay in the child -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'f(){ :; }; x=1; cd /; exit 4' > s; chmod 755 s; ./s; echo $? "[$x]"; type -t f; [ "$PWD" != / ] && echo kept ### expect 4 [] @@ -42,7 +41,6 @@ kept ### bashbox_script_file_the_script_reads_the_caller_s_stdin # the script reads the caller's stdin -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'read l; echo got $l' > s; chmod +x s; printf 'a\nb\n' | { ./s; read m; echo m=$m; } ### expect got a @@ -58,7 +56,6 @@ printf 'if then' > s; chmod +x s; ./s; echo $? ### bashbox_script_file_command_runs_a_script_file_too # command runs a script file too -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'echo in' > s; chmod +x s; command ./s ### expect in @@ -66,7 +63,6 @@ in ### bashbox_script_file_sourcing_dev_null_does_nothing # sourcing /dev/null does nothing -### skip: TODO bashbox corpus gap, bashkit output differs from real bash source /dev/null; echo $? ### expect 0 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh index 565a365c6..1e77f5aab 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh @@ -4,7 +4,6 @@ ### bashbox_syntax_error_eval_reports_and_returns_2 # eval reports and returns 2 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash eval fi; echo $? ### expect 2 @@ -12,7 +11,6 @@ eval fi; echo $? ### bashbox_syntax_error_a_sourced_file_reports_under_its_name_and_returns_2 # a sourced file reports under its name and returns 2 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo 'if then' > bad.sh; source ./bad.sh; echo $? ### expect 2 @@ -20,7 +18,6 @@ echo 'if then' > bad.sh; source ./bad.sh; echo $? ### bashbox_syntax_error_a_backquoted_substitution_is_parsed_when_it_runs_and_fails_w # a backquoted substitution is parsed when it runs and fails with 2 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=`fi`; echo "[$x] $?" ### expect [] 2 @@ -49,7 +46,6 @@ f(){ trap fi RETURN; }; f; echo $? ### bashbox_syntax_error_a_case_pattern # a case pattern -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=$(case a in a) echo A;; esac); echo "[$x]" ### expect [A] @@ -57,7 +53,6 @@ x=$(case a in a) echo A;; esac); echo "[$x]" ### bashbox_syntax_error_a_case_pattern_in_double_quotes # a case pattern in double quotes -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo "$(case b in a) echo A;; b) echo B;; esac)" ### expect B @@ -91,7 +86,6 @@ EOF ### bashbox_syntax_error_a_process_substitution # a process substitution -### skip: TODO bashbox corpus gap, bashkit output differs from real bash cat <(case a in a) echo P;; esac) ### expect P @@ -99,7 +93,6 @@ P ### bashbox_syntax_error_nested_substitutions # nested substitutions -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo $(echo $(case a in a) echo in;; esac) out) ### expect in out diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-type-printer.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-type-printer.test.sh index 9669209bc..f89bc15df 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-type-printer.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-type-printer.test.sh @@ -4,7 +4,6 @@ ### bashbox_type_printer_a_one_line_body # a one-line body -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f() { echo hi; } type f ### expect @@ -17,7 +16,6 @@ f () ### bashbox_type_printer_assignments_words_and_redirections # assignments, words and redirections -### skip: TODO bashbox corpus gap, bashkit output differs from real bash g() { a=1 b=2 cmd x 'y z' "q $v" >out 2>&1 /dev/null type f g @@ -230,7 +219,6 @@ g () ### bashbox_type_printer_here_documents_follow_the_line_that_opens_them # here-documents follow the line that opens them -### skip: TODO bashbox corpus gap, bashkit output differs from real bash n() { cat <f; echo hi one A @@ -358,7 +342,6 @@ EOF ### bashbox_type_printer_arrays_declarations_and_appends # arrays, declarations and appends -### skip: TODO bashbox corpus gap, bashkit output differs from real bash function o { local x=(1 2 3) y; declare -A z=([a]=1); x+=(4); echo "${x[@]}"; x=( 1 2 3 ); } type o @@ -376,7 +359,6 @@ o () ### bashbox_type_printer_time_substitutions_and_a_nested_function # time, substitutions and a nested function -### skip: TODO bashbox corpus gap, bashkit output differs from real bash p() { time ls; time -p ls; echo $(date) `date` $((1+2)) ${a:-b}; i() { inner; }; } type p ### expect @@ -395,7 +377,6 @@ p () ### bashbox_type_printer_redirections_of_the_function_itself # redirections of the function itself -### skip: TODO bashbox corpus gap, bashkit output differs from real bash q() { echo a; } > /dev/null 2>&1 type q ### expect @@ -408,7 +389,6 @@ q () ### bashbox_type_printer_comments_and_continuations_disappear # comments and continuations disappear -### skip: TODO bashbox corpus gap, bashkit output differs from real bash t() { echo # comment } v() { @@ -436,7 +416,6 @@ line" cont ### bashbox_type_printer_quoting_is_kept_as_written # quoting is kept as written -### skip: TODO bashbox corpus gap, bashkit output differs from real bash j() { echo a\ b "c\"d" 'e' \$x x=1; } type j ### expect @@ -449,7 +428,6 @@ j () ### bashbox_type_printer_declare_f_prints_every_function_sorted # declare -f prints every function, sorted -### skip: TODO bashbox corpus gap, bashkit output differs from real bash b() { :; } a() { echo; } declare -f @@ -466,7 +444,6 @@ b () ### bashbox_type_printer_declare_f_with_names_failing_quietly_for_a_missing_one # declare -f with names, failing quietly for a missing one -### skip: TODO bashbox corpus gap, bashkit output differs from real bash b() { :; } declare -f b nope; echo "s=$?" ### expect @@ -479,7 +456,6 @@ s=1 ### bashbox_type_printer_declare_f_lists_names # declare -F lists names -### skip: TODO bashbox corpus gap, bashkit output differs from real bash b() { :; } a() { :; } declare -F; declare -F b nope a; echo "s=$?" @@ -493,7 +469,6 @@ s=1 ### bashbox_type_printer_type_reports_each_name # type reports each name -### skip: TODO bashbox corpus gap, bashkit output differs from real bash b() { :; } type b nope; echo "s=$?"; type -t b ### expect diff --git a/crates/bashkit/tests/spec_cases/bash/type.test.sh b/crates/bashkit/tests/spec_cases/bash/type.test.sh index 575ac0977..f4da6ac14 100644 --- a/crates/bashkit/tests/spec_cases/bash/type.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/type.test.sh @@ -13,21 +13,22 @@ if is a shell keyword ### end ### type_function -### bash_diff: real bash also prints function body -# type reports functions +# type reports functions with their body myfunc() { echo hi; } type myfunc ### expect myfunc is a function +myfunc () +{ + echo hi +} ### end ### type_not_found ### exit_code:1 -### bash_diff: real bash writes error to stderr not stdout -# type exits 1 for unknown command +# type exits 1 for unknown command; the error goes to stderr type nonexistent_cmd_xyz ### expect -bash: type: nonexistent_cmd_xyz: not found ### end ### type_t_builtin From 4014ef34d8da358dc105555671765380ac157d8e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 19:35:07 +0000 Subject: [PATCH 4/6] chore(knowledge): document function printing, parser fixes, expansion error gap --- knowledge/foundations/parser.md | 53 +++++++++++++++++++++++++++++ knowledge/operations/limitations.md | 1 + 2 files changed, 54 insertions(+) diff --git a/knowledge/foundations/parser.md b/knowledge/foundations/parser.md index 072b12820..edb4e5ccd 100644 --- a/knowledge/foundations/parser.md +++ b/knowledge/foundations/parser.md @@ -133,6 +133,59 @@ position, so `echo case)` still closes. (space, tab, newline, `;`, `&`, `|`, `(`, `)`, or end); `[[:digit:]]*` is a bracket-expression word. +**Function printing (`type`, `declare -f`).** `parser/print_cmd.rs` ports +bash's `print_cmd.c` layout over the AST: `name () ` / `{ ` header, 4-space +indent, `;` + newline between commands, `elif` printed as a nested +`else if`, `for x;` as `for x in "$@";`, a lone `[[ x ]]` operand as `-n x`, +`|&` as `2>&1 |`, and here-document bodies deferred to the end of their line +(including bash's quirk where the `;` after the command following a heredoc +is dropped). Words print as written: while parsing a function definition the +parser turns on lexer raw capture, and every word built from a token keeps +its source text in `Word::raw` (top-level words leave it `None`, so ordinary +scripts pay nothing). Raw text is captured from consumed chars, not span +offsets, because heredoc rest-of-line re-injection makes offsets drift. +Compound arrays print their elements joined by one space, `(( ))` and +`for (( ))` keep their text (`ArithmeticForCommand::raw`), and +`Redirect::heredoc_delim` keeps the delimiter (`'EOF'` when quoted). A word +without source text is reconstructed from its parts (never `Debug`, +TM-INF-022). Function bodies may be any compound command with redirections +(`f() ( ... )`, `f() if ...; fi`, `f() { ...; } >log`). + +**Arithmetic commands.** `(( ... ))` and `for (( ...; ...; ... ))` are read +as raw text up to the matching `))` (`Lexer::read_dparen_body`), like bash's +`parse_dparen`, so `<<` is a shift (not a heredoc) and spacing survives; +evaluation uses `arith_exec_text` (trimmed, double quotes removed, as +`let "..."`). + +**Here-document delimiters.** The delimiter's source text decides quoting: +any `'`, `"` or `\` in it (`<<'E'`, `<<\E`, `<&1 | b` +(the `2>&1` is appended to `a`'s redirections). + +**Backquotes parse late.** Bash parses a backquoted command when it runs, so +`` x=`fi` `` fails only that substitution (status 2). The lexer converts +backquotes to `$(...)`; a body that does not parse becomes +`$(eval 'body')`, which reports the syntax error at run time and never runs +anything else. `$(...)` keeps rejecting the script at parse time. + +**Process substitution end.** `<(...)`/`>(...)` bodies end where the shared +`subst_scan` scanner closes them (`Lexer::skip_subst_body`), so +`<(case a in a) ...;; esac)` keeps the pattern's `)`; the body is still +sliced from the source, not copied (TM-DOS-021). + +**Substitutions in `${x:-...}` operands.** Operand expansion is sync, so the +`$(...)` parts of a `:-`/`:=`/`:?`/`:+` operand run ahead in +`prefetch_operand_substs` (async) and are consumed in order. They run only +when the operator uses the operand, so an unused default never executes. +`${x:-$(echo })}`: the lexer and `read_brace_operand` let a substitution own +its braces. + ## Alternatives Considered - PEG (pest, pom): rejected, bash grammar is context-sensitive, here-docs awkward, manual parser gives better errors. diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index e4e9f0555..07fc88719 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -108,6 +108,7 @@ Target: IEEE 1003.1-2024 Shell Command Language. | `time` | Reserved-word pipelines; `-p`, `--`, GNU `-f/-o/-a/-v`; elapsed time, exit status, and Bashkit counters | Host user/system CPU, RSS, and other process metrics are reported as `unavailable`, never fabricated | | `timeout` | Basic usage | `-k` kill timeout | | `bash`/`sh` | `-c`, `-n`, `-e`, `-x`, `-u`, `-f`, `-o option`, script files, stdin, `--version`, `--help` | Login shell | +| Expansion errors | `set -u` unbound variables stop the shell | `$((1/0))` evaluates to 0 and `${x!}` expands empty; bash reports them and drops the rest of the line the failing command ends on (`bashbox_line_number_an_expansion_error_drops_the_rest_of_the_line_it_ends_on`, skipped) | | Brace expansion | Runs before other expansions on unquoted literal text; lists, nesting, ranges with step and zero padding, quoted/escaped braces and commas | Works on parsed words, not raw text: `$v{1,2}` expands `$v` then appends `1`/`2` (bash reads `$v1`, `$v2`); write `${v}` | ## Builtins From ba6e635a9addaa564d2f3b24d4407769ea152481 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 21:39:58 +0000 Subject: [PATCH 5/6] fix(interpreter): keep function calls off the builtin dispatch frame A function call is the recursion path. Dispatching it from the same future as the builtin and PATH-search arms put that whole frame on the stack at every call level, so 16 nested calls overflowed a 2 MiB stack in debug builds. Functions now get their own small future. Also adds the raw field to the Word built by main's array-literal change. --- crates/bashkit/src/interpreter/mod.rs | 28 ++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index c3bca477d..cc5fd08b2 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -8013,14 +8013,27 @@ impl Interpreter { args: Vec, stdin: Option, ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - // Check for functions first - if let Some(func_def) = self.scoped.functions.get(name).cloned() { - return self - .execute_function_call(name, &func_def, args, stdin, &command.redirects) - .await; - } + // Functions first, in their own small future: a function call is the + // recursion path, and the builtin/path-search arms below would + // otherwise add their large frame to every call level + // (THREAT[TM-DOS-020]: bounded recursion on a 2 MiB stack). + if let Some(func_def) = self.scoped.functions.get(name).cloned() { + return Box::pin(async move { + self.execute_function_call(name, &func_def, args, stdin, &command.redirects) + .await + }); + } + self.dispatch_non_function(name, command, args, stdin) + } + fn dispatch_non_function<'a>( + &'a mut self, + name: &'a str, + command: &'a SimpleCommand, + args: Vec, + stdin: Option, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { // Interpreter-level special builtins if Self::is_special_builtin_name(name) { return self @@ -11897,6 +11910,7 @@ fn split_indexed_element(word: &Word) -> Option<(String, Word)> { quoted: true, has_unquoted_glob: false, part_quoted, + raw: None, }, )) } From d7e0061bca302ca840e104badda9431bf8fa29d8 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 21:53:42 +0000 Subject: [PATCH 6/6] fix(arith): negative exponent keeps evaluating to 0 The unary-sign change made 2 ** -1 parse its exponent as -1, which the clamp turned into 2 ** 0 = 1. Keep the evaluator's error value 0. --- crates/bashkit/src/interpreter/arithmetic.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/bashkit/src/interpreter/arithmetic.rs b/crates/bashkit/src/interpreter/arithmetic.rs index 62b870239..1be9433c2 100644 --- a/crates/bashkit/src/interpreter/arithmetic.rs +++ b/crates/bashkit/src/interpreter/arithmetic.rs @@ -791,6 +791,11 @@ impl Interpreter { '*' if depth == 0 && i + 1 < chars.len() && chars[i + 1] == '*' => { let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); let right = self.parse_arithmetic_impl(&expr[bo[i] + 2..], arith_depth + 1); + // Negative exponent: bash errors; this evaluator's + // error value is 0 (TM-DOS-029), not `x ** 0`. + if right < 0 { + return Some(0); + } let exp = right.clamp(0, 63) as u32; return Some(left.wrapping_pow(exp)); }