From 31f8622edbc04ff5e2250d04228ad61c67d44662 Mon Sep 17 00:00:00 2001 From: Mike Bland Date: Fri, 10 Feb 2017 21:23:27 -0500 Subject: [PATCH 1/8] Add `new` builtin command to generate scripts Closes #142. --- libexec/new | 297 ++++++++++++++++++++++++++++++++++ tests/new.bats | 422 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 719 insertions(+) create mode 100755 libexec/new create mode 100644 tests/new.bats diff --git a/libexec/new b/libexec/new new file mode 100755 index 0000000..5c0e52f --- /dev/null +++ b/libexec/new @@ -0,0 +1,297 @@ +#! /usr/bin/env bash +# +# Generate a new command script, module, test, or other text file +# +# Usage: +# To generate an arbitrary text file: +# {{go}} {{cmd}} --type [lines...] +# +# To generate a new command script in `_GO_SCRIPTS_DIR`: +# {{go}} {{cmd}} --command [...] +# +# To generate a new internal module in `_GO_SCRIPTS_DIR/lib`: +# {{go}} {{cmd}} --internal +# +# To generate a new public module in `_GO_ROOTDIR/lib`: +# {{go}} {{cmd}} --public +# +# To generate a new Bats test file in `_GO_TEST_DIR`: +# {{go}} {{cmd}} --test +# +# Where: +# +# Very brief description of the file type (can be empty) +# Path to the new file +# [lines...] Optional list of lines to add to the file +# Permissions to set for the new file +# Command script name +# Subcommand script name +# Path to module file relative to `_GO_*DIR/lib` +# Path to module file relative to `_GO_TEST_DIR` +# +# Any component of the target file path that does not yet exist will be created. +# +# If the `EDITOR` environment variable is defined, the newly-generated file (or +# files, possible with `--command`) will be opened for editing with `--command`, +# `--internal`, `--public`, or `--test`. It will be opened for other files when +# the list of `lines...` is empty. +# +# When invoking the `--command` form, this command will also generate a new +# script for any name preceding the final `` that does not yet +# correspond to an existing parent command script. These parent commands will +# invoke `@go.show_subcommands` from the core `subcommands` module by default. + +_@go.new_tab_completions() { + local word_index="$1" + local mode="$2" + shift 2 + + if [[ "$word_index" -eq '0' ]]; then + printf -- '--command --internal --public --test --type' + return + fi + ((--word_index)) + + case "$mode" in + --command) + if [[ "$word_index" -eq '0' ]]; then + _@go.source_builtin 'commands' "$_GO_SCRIPTS_DIR" + else + . "$_GO_CORE_DIR/lib/internal/complete" + _@go.complete_command_path "$word_index" "$@" + fi + return + ;; + --internal) + if [[ "$word_index" -ne '0' ]] || ! cd "$_GO_SCRIPTS_DIR/lib"; then + return 1 + fi + ;; + --public) + if [[ "$word_index" -ne '0' ]] || ! cd "$_GO_ROOTDIR/lib"; then + return 1 + fi + ;; + --test) + if [[ "$word_index" -ne '0' ]] || ! cd "$_GO_ROOTDIR/$_GO_TEST_DIR"; then + return 1 + fi + ;; + --type) + if [[ "$word_index" -ne '1' ]]; then + return 1 + fi + shift + ;; + *) + return 1 + ;; + esac + @go.compgen -f -- "$1" +} + +_@go.new_file() { + local file_type="$1" + local file_path="$2" + local permissions="$3" + shift 3 + local relpath="$file_path" + local parent_dir + local permissions_pattern='([0-7][0-7][0-7]|[ugo]{1,3}[+-][rwx]{1,3})' + + if [[ -z "$_GO_STANDALONE" ]]; then + relpath="${relpath#$_GO_ROOTDIR/}" + fi + + parent_dir="${relpath%/*}" + if [[ "$parent_dir" == "$relpath" ]]; then + parent_dir="$PWD" + fi + + if [[ -n "$file_type" ]]; then + file_type+=' ' + fi + + if [[ -z "$file_path" ]]; then + @go.printf 'No %sfile path specified.\n' "$file_type" >&2 + return 1 + elif [[ ! "$permissions" =~ $permissions_pattern ]]; then + @go.printf 'Invalid permissions specification "%s" for %sfile: %s\n' \ + "$permissions" "$file_type" "$relpath" >&2 + return 1 + elif [[ ! -d "$parent_dir" ]] && ! mkdir -p "$parent_dir"; then + @go.printf "Couldn't create parent directory for new %sfile: %s\n" \ + "$file_type" "$relpath" >&2 + return 1 + elif [[ -f "$file_path" ]]; then + @go.printf '%sfile already exists: %s\n' "$file_type" "$relpath" >&2 + return 1 + elif ! printf -- '%s\n' "$@" >"$file_path"; then + @go.printf 'Failed to create new %sfile: %s\n' "$file_type" "$relpath" >&2 + return 1 + elif ! chmod "$permissions" "$file_path"; then + @go.printf 'Failed to set permissions for new %sfile to "%s": %s\n' \ + "$file_type" "$permissions" "$relpath" >&2 + return 1 + fi +} + +_@go.new_command_script() { + local cmd="$1" + local cmd_path="$2" + local is_last_cmd="$3" + local script_impl=('#! /usr/bin/env bash' + '#' + '# Short description of the {{cmd}} command' '') + + if [[ -n "$is_last_cmd" ]]; then + script_impl+=("_$cmd() {" + ' :' + '}' + '' + "_$cmd \"\$@\"") + else + script_impl+=(". \"\$_GO_USE_MODULES\" 'subcommands'" + '' + '@go.show_subcommands') + fi + _@go.new_file "command script" "$cmd_path" '755' "${script_impl[@]}" +} + +_@go.new_command_scripts() { + local cmd + local cmd_path + local parent_dir="$_GO_SCRIPTS_DIR" + local new_scripts=() + local is_last_cmd + local i=0 + + if [[ "$#" -eq '0' ]]; then + printf 'No command script name specified.\n' >&2 + return 1 + fi + + for cmd in "$@"; do + cmd_path="$parent_dir/$cmd" + parent_dir="$cmd_path.d" + + if [[ "$((++i))" -eq "$#" ]]; then + is_last_cmd='true' + elif [[ -f "$cmd_path" ]]; then + continue + fi + new_scripts+=("$cmd_path") + + if ! _@go.new_command_script "$cmd" "$cmd_path" "$is_last_cmd"; then + return 1 + fi + done + + if command -v "$EDITOR" >/dev/null; then + "$EDITOR" "${new_scripts[@]}" + fi +} + +_@go.new_module() { + local module_path="$1" + local module_relpath="${module_path#*/lib/}" + local module_type + local impl=('#! /usr/bin/env bash' + '#' + "# Short description of the $module_relpath module" + '#' + '# Exports:' + '# func_name' + '# Short description of the func_name function') + + case "${module_path%%/lib/*}" in + $_GO_SCRIPTS_DIR) + module_type='internal module' + ;; + $_GO_ROOTDIR) + module_type='public module' + ;; + esac + + if ! _@go.new_file "$module_type" "$module_path" '644' "${impl[@]}"; then + return 1 + elif command -v "$EDITOR" >/dev/null; then + "$EDITOR" "$module_path" + fi +} + +_@go.new_test() { + local test_path="${1%.bats}.bats" + local test_relpath="${test_path#$_GO_ROOTDIR/$_GO_TEST_DIR/}" + local parent_dir="${test_relpath%/*}" + local impl + + if [[ -n "$parent_dir" ]]; then + parent_dir="${parent_dir//[^\/]}/" + fi + + impl=('#! /usr/bin/env bats' + '' + "load ${parent_dir//\//../}environment" + '' + 'setup() {' + ' test_filter' + ' @go.create_test_go_script' + '}' + '' + 'teardown() {' + ' @go.remove_test_go_rootdir' + '}' + '' + '@test "$SUITE: short description of your first test case" {' + '}') + + if ! _@go.new_file "Bats test" "$test_path" '644' "${impl[@]}"; then + return 1 + elif command -v "$EDITOR" >/dev/null; then + "$EDITOR" "$test_path" + fi +} + +_@go.new() { + local mode="$1" + + if [[ "$#" -eq '0' ]]; then + @go 'help' "${_GO_CMD_NAME[@]}" >&2 + return 1 + fi + shift + + case "$mode" in + --complete) + # Tab completions + _@go.new_tab_completions "$@" + return + ;; + --command) + _@go.new_command_scripts "$@" + ;; + --internal) + _@go.new_module "$_GO_SCRIPTS_DIR/lib/$1" + ;; + --public) + _@go.new_module "$_GO_ROOTDIR/lib/$1" + ;; + --test) + _@go.new_test "$_GO_ROOTDIR/$_GO_TEST_DIR/$1" + ;; + --type) + if ! _@go.new_file "$1" "$2" "${@:3}"; then + return 1 + elif [[ "$#" -le '3' ]] && command -v "$EDITOR" >/dev/null; then + "$EDITOR" "$2" + fi + ;; + *) + printf 'The first argument is "%s", but must be one of:\n %s\n' \ + "$mode" '--command --internal --public --test --type' >&2 + return 1 + esac +} + +_@go.new "$@" diff --git a/tests/new.bats b/tests/new.bats new file mode 100644 index 0000000..393372b --- /dev/null +++ b/tests/new.bats @@ -0,0 +1,422 @@ +#! /usr/bin/env bats + +load environment + +setup() { + test_filter + @go.create_test_go_script '@go "$@"' +} + +teardown() { + @go.remove_test_go_rootdir +} + +# Will list the file names passed as arguments for tests that check that +# `EDITOR` gets called. Tests that check that it doesn't get called should +# see no file names output. +test_editor() { + printf -- "EDITING: %s\n" "$@" +} +export -f test_editor +export EDITOR='test_editor' + +assert_command_script_is_executable() { + set "$DISABLE_BATS_SHELL_OPTIONS" + local cmd_script_path="$1" + if [[ ! -x "$TEST_GO_SCRIPTS_DIR/$cmd_script_path" ]]; then + printf 'Failed to make command script executable: %s\n' \ + "$TEST_GO_SCRIPTS_DIR/$cmd_script_path" >&2 + restore_bats_shell_options '1' + else + restore_bats_shell_options + fi +} + +@test "$SUITE: tab complete first argument" { + local flags=('--command' '--internal' '--public' '--test' '--type') + run "$TEST_GO_SCRIPT" complete 1 new '' + assert_success "${flags[@]}" + + run "$TEST_GO_SCRIPT" complete 1 new '-' + assert_success "${flags[@]}" + + run "$TEST_GO_SCRIPT" complete 1 new '--' + assert_success "${flags[@]}" + + run "$TEST_GO_SCRIPT" complete 1 new '--t' + assert_success '--test' '--type' +} + +@test "$SUITE: tab complete fails for unknown and incomplete flags" { + run "$TEST_GO_SCRIPT" complete 1 new '--foo' + assert_failure '' + + run "$TEST_GO_SCRIPT" complete 2 new '--foo' 'bar' + assert_failure '' + + @go.create_test_command_script 'foobar' + run "$TEST_GO_SCRIPT" complete 2 new '--comman' 'fooba' + assert_failure '' +} + +@test "$SUITE: tab complete --command" { + run "$TEST_GO_SCRIPT" complete 1 new '--c' + assert_success '--command ' + + @go.create_parent_and_subcommands foo bar baz + @go.create_test_command_script quux + @go.create_test_command_script xyzzy + + run "$TEST_GO_SCRIPT" complete 2 new '--command' + assert_success 'foo' 'quux' 'xyzzy' + + run "$TEST_GO_SCRIPT" complete 2 new '--command' 'f' + assert_success 'foo ' + + run "$TEST_GO_SCRIPT" complete 3 new '--command' 'foo' 'b' + assert_success 'bar' 'baz' + + run "$TEST_GO_SCRIPT" complete 4 new '--command' 'foo' 'bar' + assert_failure '' +} + +@test "$SUITE: tab complete --internal" { + run "$TEST_GO_SCRIPT" complete 1 new '--i' + assert_success '--internal ' + + local internal_modules=("$TEST_GO_SCRIPTS_DIR/lib/"{bar,baz,foo}) + mkdir -p "$TEST_GO_SCRIPTS_DIR/lib/" + touch "${internal_modules[@]}" + + run "$TEST_GO_SCRIPT" complete 2 new '--internal' + assert_success "${internal_modules[@]#$TEST_GO_SCRIPTS_DIR/lib/}" + + run "$TEST_GO_SCRIPT" complete 2 new '--internal' 'f' + assert_success 'foo ' + + run "$TEST_GO_SCRIPT" complete 3 new '--internal' 'foo' + assert_failure '' +} + +@test "$SUITE: tab complete --public" { + run "$TEST_GO_SCRIPT" complete 1 new '--p' + assert_success '--public ' + + local public_modules=("$TEST_GO_ROOTDIR/lib/"{plugh,quux,xyzzy}) + mkdir -p "$TEST_GO_ROOTDIR/lib/" + touch "${public_modules[@]}" + + run "$TEST_GO_SCRIPT" complete 2 new '--public' + assert_success "${public_modules[@]#$TEST_GO_ROOTDIR/lib/}" + + run "$TEST_GO_SCRIPT" complete 2 new '--public' 'q' + assert_success 'quux ' + + run "$TEST_GO_SCRIPT" complete 3 new '--public' 'quux' + assert_failure '' +} + +@test "$SUITE: tab complete --test" { + run "$TEST_GO_SCRIPT" complete 1 new '--te' + assert_success '--test ' + + local test_files=("$TEST_GO_ROOTDIR/tests/"{aimfiz,blorple,frotz}.bats) + mkdir -p "$TEST_GO_ROOTDIR/tests/" + touch "${test_files[@]}" + + run "$TEST_GO_SCRIPT" complete 2 new '--test' + assert_success "${test_files[@]#$TEST_GO_ROOTDIR/tests/}" + + run "$TEST_GO_SCRIPT" complete 2 new '--test' 'f' + assert_success 'frotz.bats ' + + run "$TEST_GO_SCRIPT" complete 3 new '--test' 'frotz.bats' + assert_failure '' +} + +@test "$SUITE: tab complete --type" { + run "$TEST_GO_SCRIPT" complete 1 new '--ty' + assert_success '--type ' + + local text_files=("$TEST_GO_ROOTDIR/gue/"{dungeonmaster,thief,wizard}.txt) + mkdir -p "$TEST_GO_ROOTDIR/gue" + touch "${text_files[@]}" + + run "$TEST_GO_SCRIPT" complete 2 new '--type' + assert_failure '' + + run "$TEST_GO_SCRIPT" complete 3 new '--type' 'adversary' 'g' + assert_success 'go' 'gue/' + + run "$TEST_GO_SCRIPT" complete 3 new '--type' 'adversary' 'gu' + assert_success 'gue/' + + run "$TEST_GO_SCRIPT" complete 3 new '--type' 'adversary' 'gue/' + assert_success "${text_files[@]#$TEST_GO_ROOTDIR/}" + + run "$TEST_GO_SCRIPT" complete 3 new '--type' 'adversary' 'gue/t' + assert_success 'gue/thief.txt ' + + run "$TEST_GO_SCRIPT" complete 4 new '--type' 'adversary' 'gue/thief.txt' + assert_failure '' +} + +@test "$SUITE: show help with exit with error when no args" { + run "$TEST_GO_SCRIPT" new + assert_failure + assert_line_matches '0' "^$TEST_GO_SCRIPT new - Generate a new .* file\$" +} + +@test "$SUITE: exit with error on invalid first argument/mode flag" { + run "$TEST_GO_SCRIPT" new foo bar + assert_failure 'The first argument is "foo", but must be one of:' \ + ' --command --internal --public --test --type' +} + +@test "$SUITE: creating a file without lines opens EDITOR if found" { + run "$TEST_GO_SCRIPT" new --type '' foo.txt 644 + assert_success 'EDITING: foo.txt' + assert_file_equals "$TEST_GO_ROOTDIR/foo.txt" '' + + EDITOR= run "$TEST_GO_SCRIPT" new --type '' bar/baz.txt 'ugo+rwx' + assert_success '' + assert_file_equals "$TEST_GO_ROOTDIR/bar/baz.txt" '' +} + +@test "$SUITE: creating a file with lines doesn't open EDITOR" { + run "$TEST_GO_SCRIPT" new --type '' foo.txt 644 bar baz quux + assert_success '' + assert_file_equals "$TEST_GO_ROOTDIR/foo.txt" 'bar' 'baz' 'quux' +} + +@test "$SUITE: error when creating a file without specifying the path" { + run "$TEST_GO_SCRIPT" new --type '' + assert_failure 'No file path specified.' + + run "$TEST_GO_SCRIPT" new --type 'foo' + assert_failure 'No foo file path specified.' +} + +@test "$SUITE: error when creating a file with invalid permission spec" { + run "$TEST_GO_SCRIPT" new --type '' 'foo.txt' 'rwx-ugo' + assert_failure 'Invalid permissions specification "rwx-ugo" for file: foo.txt' + + run "$TEST_GO_SCRIPT" new --type 'foo' 'foo.txt' '800' + assert_failure 'Invalid permissions specification "800" for foo file: foo.txt' +} + +@test "$SUITE: error if creating parent directory fails" { + stub_program_in_path 'mkdir' 'printf "ARG: %s\n" "$@"' 'exit 1' + + run "$TEST_GO_SCRIPT" new --type '' foo/bar.txt 644 + assert_failure 'ARG: -p' 'ARG: foo' \ + "Couldn't create parent directory for new file: foo/bar.txt" + + run "$TEST_GO_SCRIPT" new --type 'foo' foo/bar.txt 'ugo+rwx' + assert_failure 'ARG: -p' 'ARG: foo' \ + "Couldn't create parent directory for new foo file: foo/bar.txt" +} + +@test "$SUITE: error if file already exists" { + mkdir -p "$TEST_GO_ROOTDIR/foo" + touch "$TEST_GO_ROOTDIR/foo/bar.txt" + + run "$TEST_GO_SCRIPT" new --type '' foo/bar.txt 644 + assert_failure 'file already exists: foo/bar.txt' + + run "$TEST_GO_SCRIPT" new --type 'foo' foo/bar.txt 'ugo+rwx' + assert_failure 'foo file already exists: foo/bar.txt' +} + +@test "$SUITE: error if printing to file fails" { + stub_program_in_path 'mkdir' 'printf "ARG: %s\n" "$@"' \ + 'printf "DIR NOT CREATED\n"' + + local sys_err_regex="^$_GO_CORE_DIR/libexec/new: line [1-9][0-9]*: " + sys_err_regex+='foo/bar.txt: No such file or directory$' + + local expected=('^ARG: -p$' '^ARG: foo$' '^DIR NOT CREATED$' "$sys_err_regex") + + run "$TEST_GO_SCRIPT" new --type '' foo/bar.txt 644 + assert_failure + assert_lines_match "${expected[@]}" \ + '^Failed to create new file: foo/bar.txt$' + + run "$TEST_GO_SCRIPT" new --type 'foo' foo/bar.txt 'ugo+rwx' + assert_lines_match "${expected[@]}" \ + '^Failed to create new foo file: foo/bar.txt$' +} + +@test "$SUITE: error if setting permissions fails" { + stub_program_in_path 'chmod' 'printf "ARG: %s\n" "$@"' 'exit 1' + + run "$TEST_GO_SCRIPT" new --type '' foo/bar.txt 644 + assert_failure 'ARG: 644' 'ARG: foo/bar.txt' \ + 'Failed to set permissions for new file to "644": foo/bar.txt' + + rm "$TEST_GO_ROOTDIR/foo/bar.txt" + run "$TEST_GO_SCRIPT" new --type 'foo' foo/bar.txt 'ugo+rwx' + assert_failure 'ARG: ugo+rwx' 'ARG: foo/bar.txt' \ + 'Failed to set permissions for new foo file to "ugo+rwx": foo/bar.txt' +} + +@test "$SUITE: error messages don't trim _GO_ROOTDIR in _GO_STANDALONE mode" { + mkdir -p "$TEST_GO_ROOTDIR/foo" + touch "$TEST_GO_ROOTDIR/foo/bar.txt" + + _GO_STANDALONE='true' run "$TEST_GO_SCRIPT" new --type '' \ + "$TEST_GO_ROOTDIR/foo/bar.txt" 644 + assert_failure "file already exists: $TEST_GO_ROOTDIR/foo/bar.txt" +} + +@test "$SUITE: new command script" { + run "$TEST_GO_SCRIPT" new --command foo + assert_success "EDITING: $TEST_GO_SCRIPTS_DIR/foo" + assert_file_equals "$TEST_GO_SCRIPTS_DIR/foo" \ + '#! /usr/bin/env bash' \ + '#' \ + '# Short description of the {{cmd}} command' \ + '' \ + '_foo() {' \ + ' :' \ + '}' \ + '' \ + '_foo "$@"' + assert_command_script_is_executable 'foo' + + rm "$TEST_GO_SCRIPTS_DIR/foo" + EDITOR= run "$TEST_GO_SCRIPT" new --command foo + assert_success '' + assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo" $'\n_foo "\$@"' +} + +@test "$SUITE: new subcommand script" { + run "$TEST_GO_SCRIPT" new --command foo + assert_success "EDITING: $TEST_GO_SCRIPTS_DIR/foo" + assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo" $'\n_foo\(\) {\n' + assert_command_script_is_executable 'foo' + + run "$TEST_GO_SCRIPT" new --command foo bar + assert_success "EDITING: $TEST_GO_SCRIPTS_DIR/foo.d/bar" + assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo.d/bar" $'\n_bar\(\) {\n' + assert_command_script_is_executable 'foo.d/bar' +} + +@test "$SUITE: new command and subcommand scripts" { + run "$TEST_GO_SCRIPT" new --command foo bar baz + assert_success "EDITING: $TEST_GO_SCRIPTS_DIR/foo" \ + "EDITING: $TEST_GO_SCRIPTS_DIR/foo.d/bar" \ + "EDITING: $TEST_GO_SCRIPTS_DIR/foo.d/bar.d/baz" + + assert_file_equals "$TEST_GO_SCRIPTS_DIR/foo" \ + '#! /usr/bin/env bash' \ + '#' \ + '# Short description of the {{cmd}} command' \ + '' \ + ". \"\$_GO_USE_MODULES\" 'subcommands'" \ + '' \ + '@go.show_subcommands' + assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo.d/bar" '@go.show_subcommands' + assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo.d/bar.d/baz" $'\n_baz\(\) {\n' + + assert_command_script_is_executable 'foo' + assert_command_script_is_executable 'foo.d/bar' + assert_command_script_is_executable 'foo.d/bar.d/baz' +} + +@test "$SUITE: --command fails if no script specified" { + run "$TEST_GO_SCRIPT" new --command + assert_failure 'No command script name specified.' +} + +@test "$SUITE: --command fails if script already exists" { + run "$TEST_GO_SCRIPT" new --command foo bar baz + assert_success "EDITING: $TEST_GO_SCRIPTS_DIR/foo" \ + "EDITING: $TEST_GO_SCRIPTS_DIR/foo.d/bar" \ + "EDITING: $TEST_GO_SCRIPTS_DIR/foo.d/bar.d/baz" + run "$TEST_GO_SCRIPT" new --command foo bar baz + + local failing_path="$TEST_GO_SCRIPTS_RELATIVE_DIR/foo.d/bar.d/baz" + assert_failure "command script file already exists: $failing_path" +} + +@test "$SUITE: --internal creates new internal module" { + run "$TEST_GO_SCRIPT" new --internal foo + assert_success "EDITING: $TEST_GO_SCRIPTS_DIR/lib/foo" + assert_file_equals "$TEST_GO_SCRIPTS_DIR/lib/foo" \ + '#! /usr/bin/env bash' \ + '#' \ + '# Short description of the foo module' \ + '#' \ + '# Exports:' \ + '# func_name' \ + '# Short description of the func_name function' + + rm "$TEST_GO_SCRIPTS_DIR/lib/foo" + EDITOR= run "$TEST_GO_SCRIPT" new --internal foo + assert_success '' + assert_file_matches "$TEST_GO_SCRIPTS_DIR/lib/foo" \ + $'\n# Short description of the foo module\n' +} + +@test "$SUITE: --internal fails if internal module already exists" { + run "$TEST_GO_SCRIPT" new --internal foo + assert_success "EDITING: $TEST_GO_SCRIPTS_DIR/lib/foo" + run "$TEST_GO_SCRIPT" new --internal foo + assert_failure \ + "internal module file already exists: $TEST_GO_SCRIPTS_RELATIVE_DIR/lib/foo" +} + +@test "$SUITE: --public creates new public module" { + run "$TEST_GO_SCRIPT" new --public foo/bar/baz + assert_success "EDITING: $TEST_GO_ROOTDIR/lib/foo/bar/baz" + assert_file_matches "$TEST_GO_ROOTDIR/lib/foo/bar/baz" \ + $'\n# Short description of the foo/bar/baz module\n' + + rm "$TEST_GO_ROOTDIR/lib/foo/bar/baz" + EDITOR= run "$TEST_GO_SCRIPT" new --public foo/bar/baz + assert_success '' + assert_file_matches "$TEST_GO_ROOTDIR/lib/foo/bar/baz" \ + $'\n# Short description of the foo/bar/baz module\n' +} + +@test "$SUITE: --public fails if public module already exists" { + run "$TEST_GO_SCRIPT" new --public foo/bar/baz + assert_success "EDITING: $TEST_GO_ROOTDIR/lib/foo/bar/baz" + run "$TEST_GO_SCRIPT" new --public foo/bar/baz + assert_failure 'public module file already exists: lib/foo/bar/baz' +} + +@test "$SUITE: --test creates new Bats test file" { + run "$TEST_GO_SCRIPT" new --test foo/bar/baz + assert_success "EDITING: $TEST_GO_ROOTDIR/$_GO_TEST_DIR/foo/bar/baz.bats" + assert_file_equals "$TEST_GO_ROOTDIR/$_GO_TEST_DIR/foo/bar/baz.bats" \ + '#! /usr/bin/env bats' \ + '' \ + 'load ../../environment' \ + '' \ + 'setup() {' \ + ' test_filter' \ + ' @go.create_test_go_script' \ + '}' \ + '' \ + 'teardown() {' \ + ' @go.remove_test_go_rootdir' \ + '}' \ + '' \ + '@test "$SUITE: short description of your first test case" {' \ + '}' + + rm "$TEST_GO_ROOTDIR/$_GO_TEST_DIR/foo/bar/baz.bats" + EDITOR= run "$TEST_GO_SCRIPT" new --test foo/bar/baz + assert_success '' + assert_file_matches "$TEST_GO_ROOTDIR/$_GO_TEST_DIR/foo/bar/baz.bats" \ + $'\n@test "\$SUITE: short description of your first test case" {\n' +} + +@test "$SUITE: --test fails if public module already exists" { + run "$TEST_GO_SCRIPT" new --test foo/bar/baz + assert_success "EDITING: $TEST_GO_ROOTDIR/$_GO_TEST_DIR/foo/bar/baz.bats" + run "$TEST_GO_SCRIPT" new --test foo/bar/baz + assert_failure "Bats test file already exists: $_GO_TEST_DIR/foo/bar/baz.bats" +} From bb21e637c1b1fe322022cbd84f62501ec3466bb3 Mon Sep 17 00:00:00 2001 From: Mike Bland Date: Sun, 19 Mar 2017 12:06:34 -0400 Subject: [PATCH 2/8] core: Fix version check Previously it would report that Bash 4.0 and 4.1 were unsupported. Since Bash always sets `BASH_VERSION`, it's not practical to write an automated test unless different versions of the Bash binary are available. I may add one, but since I've tested it manually on my machine and this condition isn't likely to ever change, it's probably not worth it. --- go-core.bash | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/go-core.bash b/go-core.bash index 2063c45..9906b28 100755 --- a/go-core.bash +++ b/go-core.bash @@ -26,7 +26,8 @@ # https://mike-bland.com/ # https://github.com/mbland -if [[ "${BASH_VERSINFO[0]}" -lt '3' || "${BASH_VERSINFO[1]}" -lt '2' ]]; then +if [[ "${BASH_VERSINFO[0]}" -lt '3' || + ( "${BASH_VERSINFO[0]}" -eq '3' && "${BASH_VERSINFO[1]}" -lt '2' ) ]]; then printf "This module requires bash version 3.2 or greater:\n %s %s\n" \ "$BASH" "$BASH_VERSION" exit 1 From 0d8bd294eaf710cf9b7ce1deb93d766f475b4f47 Mon Sep 17 00:00:00 2001 From: Mike Bland Date: Sat, 18 Mar 2017 14:10:25 -0400 Subject: [PATCH 3/8] new: Fix tests under 3.2.57(1)-release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under Bash 3.2.57(1)-release, `./go test new` was failing with: ``` ✗ new: tab complete --internal (in test file tests/new.bats, line 76) `touch "${internal_modules[@]}"' failed touch: (/var/folders/dl/0j29q1wd0w715j4gnz5ry_pc0000gn/T/test rootdir/scripts/lib/foo): No such file or directory ✗ new: tab complete --public (in test file tests/new.bats, line 94) `touch "${public_modules[@]}"' failed touch: (/var/folders/dl/0j29q1wd0w715j4gnz5ry_pc0000gn/T/test rootdir/lib/xyzzy): No such file or directory ✗ new: tab complete --test (in test file tests/new.bats, line 112) `touch "${test_files[@]}"' failed touch: (/var/folders/dl/0j29q1wd0w715j4gnz5ry_pc0000gn/T/test rootdir/tests/frotz.bats): No such file or directory ✗ new: tab complete --type (in test file tests/new.bats, line 130) `touch "${text_files[@]}"' failed touch: (/var/folders/dl/0j29q1wd0w715j4gnz5ry_pc0000gn/T/test rootdir/gue/wizard.txt): No such file or directory ``` At first I thought this was the "declare and initialize an array at the same time" bug from commit b421c7382fc1dafb4d865d2357276168eac30744 and commit c6bf1cf46c7816c969a0c5d45a4badeb50963f95, as the workaround was the same: declare the array on one line, and initialize it on another. After thinking it through, however, I realized this bug was different, since the earlier bug had to do with exported arrays not getting initialized, and these arrays were `local`. On top of that, it appeared that the brace expansion was to blame, since `touch` appeared to see only the final brace expansion value, and that value was wrapped in parentheses. To verify this, I added this line before one of the `touch` calls: printf 'ARG: %s\n' "${internal_modules[@]}" >&2 which produced: ✗ new: tab complete --internal (in test file tests/new.bats, line 77) `touch "${internal_modules[@]}"' failed ARG: (/var/folders/dl/0j29q1wd0w715j4gnz5ry_pc0000gn/T/test rootdir/scripts/lib/foo) touch: (/var/folders/dl/0j29q1wd0w715j4gnz5ry_pc0000gn/T/test rootdir/scripts/lib/foo): No such file or directory After reviewing https://tiswww.case.edu/php/chet/bash/CHANGES, this appeared to be the most likely culprit: This document details the changes between this version, bash-4.1-alpha, and the previous version, bash-4.0-release. bb. Fixed a bug that caused brace expansion to take place too soon in some compound array assignments. Using the methodology described in the log message for commit 99ab7805e6ef0a14568d8a100eec03bb2cb03631, I downloaded the Bash 4.0 and 4.1 sources and patches, and confirmed that the bug manifested under Bash 4.0.44 (the highest patchlevel for 4.0) and did not manifest under Bash 4.1. Also, for future reference, the official Bash git repository is at: https://savannah.gnu.org/git/?group=bash http://git.savannah.gnu.org/cgit/bash.git However, neither the patches from https://mirrors.ocf.berkeley.edu/gnu/bash/ nor the Git repository show a specific change for the fix, and the diff between 4.0.44 and 4.1 is too large and difficult to parse to easily identify the fix. --- tests/new.bats | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/new.bats b/tests/new.bats index 393372b..cb9ae5c 100644 --- a/tests/new.bats +++ b/tests/new.bats @@ -84,7 +84,8 @@ assert_command_script_is_executable() { run "$TEST_GO_SCRIPT" complete 1 new '--i' assert_success '--internal ' - local internal_modules=("$TEST_GO_SCRIPTS_DIR/lib/"{bar,baz,foo}) + local internal_modules + internal_modules=("$TEST_GO_SCRIPTS_DIR/lib/"{bar,baz,foo}) mkdir -p "$TEST_GO_SCRIPTS_DIR/lib/" touch "${internal_modules[@]}" @@ -102,7 +103,8 @@ assert_command_script_is_executable() { run "$TEST_GO_SCRIPT" complete 1 new '--p' assert_success '--public ' - local public_modules=("$TEST_GO_ROOTDIR/lib/"{plugh,quux,xyzzy}) + local public_modules + public_modules=("$TEST_GO_ROOTDIR/lib/"{plugh,quux,xyzzy}) mkdir -p "$TEST_GO_ROOTDIR/lib/" touch "${public_modules[@]}" @@ -120,7 +122,8 @@ assert_command_script_is_executable() { run "$TEST_GO_SCRIPT" complete 1 new '--te' assert_success '--test ' - local test_files=("$TEST_GO_ROOTDIR/tests/"{aimfiz,blorple,frotz}.bats) + local test_files + test_files=("$TEST_GO_ROOTDIR/tests/"{aimfiz,blorple,frotz}.bats) mkdir -p "$TEST_GO_ROOTDIR/tests/" touch "${test_files[@]}" @@ -138,7 +141,8 @@ assert_command_script_is_executable() { run "$TEST_GO_SCRIPT" complete 1 new '--ty' assert_success '--type ' - local text_files=("$TEST_GO_ROOTDIR/gue/"{dungeonmaster,thief,wizard}.txt) + local text_files + text_files=("$TEST_GO_ROOTDIR/gue/"{dungeonmaster,thief,wizard}.txt) mkdir -p "$TEST_GO_ROOTDIR/gue" touch "${text_files[@]}" From b2dac0d31a0b8a9e8e3c519d4c58c636e26240bf Mon Sep 17 00:00:00 2001 From: Mike Bland Date: Mon, 20 Mar 2017 12:24:45 -0400 Subject: [PATCH 4/8] new: Fix test failures on Ubuntu Linux Unsorted `compgen` and unescaped `{` characters in regular expressions strike again. Also, though the coverage report only shows 82.9%, that's because `kcov` isn't recognizing any of the array assignments as executed code. Coverage is actually 100%. --- tests/new.bats | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/new.bats b/tests/new.bats index cb9ae5c..8c2bdaf 100644 --- a/tests/new.bats +++ b/tests/new.bats @@ -89,8 +89,11 @@ assert_command_script_is_executable() { mkdir -p "$TEST_GO_SCRIPTS_DIR/lib/" touch "${internal_modules[@]}" + local expected + @go.test_compgen 'expected' "$TEST_GO_SCRIPTS_DIR/lib" + run "$TEST_GO_SCRIPT" complete 2 new '--internal' - assert_success "${internal_modules[@]#$TEST_GO_SCRIPTS_DIR/lib/}" + assert_success "${expected[@]}" run "$TEST_GO_SCRIPT" complete 2 new '--internal' 'f' assert_success 'foo ' @@ -146,11 +149,14 @@ assert_command_script_is_executable() { mkdir -p "$TEST_GO_ROOTDIR/gue" touch "${text_files[@]}" + local expected + @go.test_compgen 'expected' "$TEST_GO_ROOTDIR/gue" + run "$TEST_GO_SCRIPT" complete 2 new '--type' assert_failure '' run "$TEST_GO_SCRIPT" complete 3 new '--type' 'adversary' 'g' - assert_success 'go' 'gue/' + assert_success "${expected[@]}" run "$TEST_GO_SCRIPT" complete 3 new '--type' 'adversary' 'gu' assert_success 'gue/' @@ -297,12 +303,12 @@ assert_command_script_is_executable() { @test "$SUITE: new subcommand script" { run "$TEST_GO_SCRIPT" new --command foo assert_success "EDITING: $TEST_GO_SCRIPTS_DIR/foo" - assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo" $'\n_foo\(\) {\n' + assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo" $'\n_foo\(\) \{\n' assert_command_script_is_executable 'foo' run "$TEST_GO_SCRIPT" new --command foo bar assert_success "EDITING: $TEST_GO_SCRIPTS_DIR/foo.d/bar" - assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo.d/bar" $'\n_bar\(\) {\n' + assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo.d/bar" $'\n_bar\(\) \{\n' assert_command_script_is_executable 'foo.d/bar' } @@ -321,7 +327,7 @@ assert_command_script_is_executable() { '' \ '@go.show_subcommands' assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo.d/bar" '@go.show_subcommands' - assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo.d/bar.d/baz" $'\n_baz\(\) {\n' + assert_file_matches "$TEST_GO_SCRIPTS_DIR/foo.d/bar.d/baz" $'\n_baz\(\) \{\n' assert_command_script_is_executable 'foo' assert_command_script_is_executable 'foo.d/bar' @@ -415,7 +421,7 @@ assert_command_script_is_executable() { EDITOR= run "$TEST_GO_SCRIPT" new --test foo/bar/baz assert_success '' assert_file_matches "$TEST_GO_ROOTDIR/$_GO_TEST_DIR/foo/bar/baz.bats" \ - $'\n@test "\$SUITE: short description of your first test case" {\n' + $'\n@test "\$SUITE: short description of your first test case" \{\n' } @test "$SUITE: --test fails if public module already exists" { From 4b9b1375cb4621d3b3de6475e2a3a11b99f61302 Mon Sep 17 00:00:00 2001 From: Mike Bland Date: Fri, 27 Jan 2017 20:07:19 -0500 Subject: [PATCH 5/8] new: Fix tests on Arch Linux The process of fixing these failures also exposed a silent failure in `@go.test_compgen` that will be addressed in the next commit. --- tests/new.bats | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/new.bats b/tests/new.bats index 8c2bdaf..2f88d7f 100644 --- a/tests/new.bats +++ b/tests/new.bats @@ -90,10 +90,10 @@ assert_command_script_is_executable() { touch "${internal_modules[@]}" local expected - @go.test_compgen 'expected' "$TEST_GO_SCRIPTS_DIR/lib" + @go.test_compgen 'expected' -f "$TEST_GO_SCRIPTS_DIR/lib" run "$TEST_GO_SCRIPT" complete 2 new '--internal' - assert_success "${expected[@]}" + assert_success "${expected[@]#$TEST_GO_SCRIPTS_DIR/lib/}" run "$TEST_GO_SCRIPT" complete 2 new '--internal' 'f' assert_success 'foo ' @@ -111,8 +111,11 @@ assert_command_script_is_executable() { mkdir -p "$TEST_GO_ROOTDIR/lib/" touch "${public_modules[@]}" + local expected + @go.test_compgen 'expected' -f "$TEST_GO_ROOTDIR/lib" + run "$TEST_GO_SCRIPT" complete 2 new '--public' - assert_success "${public_modules[@]#$TEST_GO_ROOTDIR/lib/}" + assert_success "${expected[@]#$TEST_GO_ROOTDIR/lib/}" run "$TEST_GO_SCRIPT" complete 2 new '--public' 'q' assert_success 'quux ' @@ -130,8 +133,11 @@ assert_command_script_is_executable() { mkdir -p "$TEST_GO_ROOTDIR/tests/" touch "${test_files[@]}" + local expected + @go.test_compgen 'expected' -f "$TEST_GO_ROOTDIR/$_GO_TEST_DIR" + run "$TEST_GO_SCRIPT" complete 2 new '--test' - assert_success "${test_files[@]#$TEST_GO_ROOTDIR/tests/}" + assert_success "${expected[@]#$TEST_GO_ROOTDIR/$_GO_TEST_DIR/}" run "$TEST_GO_SCRIPT" complete 2 new '--test' 'f' assert_success 'frotz.bats ' @@ -150,19 +156,20 @@ assert_command_script_is_executable() { touch "${text_files[@]}" local expected - @go.test_compgen 'expected' "$TEST_GO_ROOTDIR/gue" + @go.test_compgen 'expected' -f "$TEST_GO_ROOTDIR/g" run "$TEST_GO_SCRIPT" complete 2 new '--type' assert_failure '' run "$TEST_GO_SCRIPT" complete 3 new '--type' 'adversary' 'g' - assert_success "${expected[@]}" + assert_success "${expected[@]#$TEST_GO_ROOTDIR/}" run "$TEST_GO_SCRIPT" complete 3 new '--type' 'adversary' 'gu' assert_success 'gue/' + @go.test_compgen 'expected' -f "$TEST_GO_ROOTDIR/gue/" run "$TEST_GO_SCRIPT" complete 3 new '--type' 'adversary' 'gue/' - assert_success "${text_files[@]#$TEST_GO_ROOTDIR/}" + assert_success "${expected[@]#$TEST_GO_ROOTDIR/}" run "$TEST_GO_SCRIPT" complete 3 new '--type' 'adversary' 'gue/t' assert_success 'gue/thief.txt ' From 84d10bfcf74f162851961c99781df16268c620e0 Mon Sep 17 00:00:00 2001 From: Mike Bland Date: Fri, 27 Jan 2017 20:08:20 -0500 Subject: [PATCH 6/8] testing/env: Make `@go.test_compgen` fail loudly The previous commit rectified errors in assertions from `tests/new.bats` whereby assertions that should have failed as written did not. Though the code under test was sound, these latent test errors were due to the fact that `@go.test_compgen` would produce an empty result array and return success if the underlying `@go.compgen` returned empty results. When this empty array was passed to `assert_success` or `assert_failure` using "${array[@]}" notation, it expanded to an empty argument list, in which case these assertions did not check the value of `$output`. This left the impression that the output matched a nonempty list of values when the assertions were actually not comparing the output at all. Now `@go.test_compgen` will fail loudly with `@go.compgen` results are empty, and any case in which the underlying `@go.compgen` returns an error. --- lib/testing/environment | 13 ++++- tests/testing/environment.bats | 30 ----------- tests/testing/environment/test-compgen.bats | 56 +++++++++++++++++++++ 3 files changed, 68 insertions(+), 31 deletions(-) create mode 100644 tests/testing/environment/test-compgen.bats diff --git a/lib/testing/environment b/lib/testing/environment index ad8124a..250b0ad 100644 --- a/lib/testing/environment +++ b/lib/testing/environment @@ -120,8 +120,19 @@ test-go() { # ...: Arguments passed directly to `@go.compgen` @go.test_compgen() { set "$DISABLE_BATS_SHELL_OPTIONS" + local completions + local err_args + . "$_GO_USE_MODULES" 'complete' 'strings' - @go.split $'\n' "$(@go.compgen "${@:2}")" "$1" + completions="$(@go.compgen "${@:2}")" + + if [[ "$?" -ne '0' || -z "$completions" ]]; then + printf -v 'err_args' ' "%s"' "${@:2}" + printf 'compgen failed or results were empty:%s\n' "$err_args" >&2 + restore_bats_shell_options '1' + return + fi + @go.split $'\n' "$completions" "$1" restore_bats_shell_options "$?" } diff --git a/tests/testing/environment.bats b/tests/testing/environment.bats index 1f689f4..8311cdd 100644 --- a/tests/testing/environment.bats +++ b/tests/testing/environment.bats @@ -93,33 +93,3 @@ teardown() { run test-go assert_success '_GO_CMD: test-go' } - -setup_go_test_compgen() { - local item - - mkdir -p "$TEST_GO_ROOTDIR/lib" - printf 'foo' >"$TEST_GO_ROOTDIR/lib/foo" - printf 'bar' >"$TEST_GO_ROOTDIR/lib/bar" - printf 'baz' >"$TEST_GO_ROOTDIR/lib/baz" - - . "$_GO_USE_MODULES" 'complete' - while IFS= read -r item; do - __expected+=("${item#$TEST_GO_ROOTDIR/}") - done < <(@go.compgen -f -- "$TEST_GO_ROOTDIR/lib/") -} - -@test "$SUITE: @go.test_compgen" { - set "$DISABLE_BATS_SHELL_OPTIONS" - local __expected=() - setup_go_test_compgen - restore_bats_shell_options "$?" - - export -f @go.test_compgen - @go.create_test_go_script \ - 'declare results=()' \ - '@go.test_compgen "results" -f -- lib/' \ - 'printf "%s\n" "${results[@]}"' - - run "$TEST_GO_SCRIPT" - assert_success "${__expected[@]}" -} diff --git a/tests/testing/environment/test-compgen.bats b/tests/testing/environment/test-compgen.bats new file mode 100644 index 0000000..793bd77 --- /dev/null +++ b/tests/testing/environment/test-compgen.bats @@ -0,0 +1,56 @@ +#! /usr/bin/env bats + +load ../../environment + +EXPECTED=() + +setup() { + test_filter + @go.create_test_go_script + export -f @go.test_compgen + + set "$DISABLE_BATS_SHELL_OPTIONS" + setup_go_test_compgen + restore_bats_shell_options "$?" + + @go.create_test_go_script \ + 'declare results=()' \ + 'if ! @go.test_compgen "results" "$@"; then' \ + ' exit 1' \ + 'fi' \ + 'printf "%s\n" "${results[@]}"' +} + +teardown() { + @go.remove_test_go_rootdir +} + +setup_go_test_compgen() { + local item + + mkdir -p "$TEST_GO_ROOTDIR/lib" + printf 'foo' >"$TEST_GO_ROOTDIR/lib/foo" + printf 'bar' >"$TEST_GO_ROOTDIR/lib/bar" + printf 'baz' >"$TEST_GO_ROOTDIR/lib/baz" + + . "$_GO_USE_MODULES" 'complete' + while IFS= read -r item; do + EXPECTED+=("${item#$TEST_GO_ROOTDIR/}") + done < <(@go.compgen -f -- "$TEST_GO_ROOTDIR/lib/") +} + +@test "$SUITE: completion succeeds" { + run "$TEST_GO_SCRIPT" -f -- lib/ + assert_success "${EXPECTED[@]}" +} + +@test "$SUITE: completion fails" { + run "$TEST_GO_SCRIPT" -f -- lib/nonexistent + assert_failure \ + 'compgen failed or results were empty: "-f" "--" "lib/nonexistent"' +} + +@test "$SUITE: fails if completions are empty" { + run "$TEST_GO_SCRIPT" lib/nonexistent + assert_failure 'compgen failed or results were empty: "lib/nonexistent"' +} From 5abb59b686ef13894f6979a11931ce007a58e850 Mon Sep 17 00:00:00 2001 From: Mike Bland Date: Mon, 20 Mar 2017 15:55:27 -0400 Subject: [PATCH 7/8] new: Fix test failures for Bash < 4.2.32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous arguments to `@go.test_compgen` should have ended with a trailing slash; as it stood, it was returning the directory itself, with a trailing slash appended. After some experimentation, it emerged that removing the directory prefix (with trailing slash) from the expected results (e.g. `$TESTS_GO_SCRIPTS_DIR/lib/`) resulted in passing the empty string as the sole argument to `assert_success` through Bash 4.2.31, and in passing no arguments at all to `assert_success` from Bash 4.2.32 on. Again using the methodology described in the log message for commit 99ab7805e6ef0a14568d8a100eec03bb2cb03631, I built different Bash versions and performed a binary search using the following command until I pinpointed 4.2.32 as the inflection point, replacing `BASH_VERSION` in `PATH` at each iteration: $ PATH=/usr/local/bash-builds/BASH_VERSION/bin:$PATH \ TEST_FILTER='tab complete --internal' gos test new I then added the following line to `assert_success` from `lib/bats/assertions`: printf 'ARGC: %d\n' "$#" >&2 and added `return 1` after the `assert_success` call in the `tab complete --internal` test case to force an error under 4.2.32, which produced the following results (edited for clarity): $ PATH=/usr/local/bash-builds/4.2.31/bin:$PATH \ TEST_FILTER='tab complete --internal' gos test new ✗ new: tab complete --internal (in test file tests/new.bats, line 96) `assert_success "${expected[@]#$TEST_GO_SCRIPTS_DIR/lib/}"' failed ARGC: 1 ARGC: 1 output not equal to expected value: expected: '' actual: 'bar baz foo' $ PATH=/usr/local/bash-builds/4.2.32/bin:$PATH \ TEST_FILTER='tab complete --internal' gos test new ✗ new: tab complete --internal (in test file tests/new.bats, line 96) `assert_success "${expected[@]#$TEST_GO_SCRIPTS_DIR/lib/}"' failed ARGC: 1 ARGC: 0 Later I reproduced this behavior using a standalone script, `expand-empty-list-test.bash`: #! /usr/bin/env bash print_argc() { printf 'ARGC: %d\n' "$#" for arg in "$@"; do printf 'ARG: "%s"\n' "$arg" done } ARGV=('foo') print_argc "${ARGV[@]#foo}" The last line can also be replaced with the following to achieve the same effect: set - "${ARGV[@]#foo}" print_argc "$@" Running either version of this script produces the output: $ /usr/local/bash-builds/4.2.31/bin/bash expand-empty-list-test.bash ARGC: 1 ARG: "" $ /usr/local/bash-builds/4.2.32/bin/bash expand-empty-list-test.bash ARGC: 0 Also, this behavior _only_ manifests when an array containing a single value is expanded such that all of its characters are removed. Various other values for `ARGV` (including the empty list) that result in more than one value (even more than one empty string) do not exhibit this behavior. Any form of expansion that removes all the characters from every list item (including `##*`, `%%*`, and `//*/`) only exhibit this behavior when `ARGV` contains a single string. This is the patch and bug report for Bash 4.2.32: https://mirrors.ocf.berkeley.edu/gnu/bash/bash-4.2-patches/bash42-032 http://lists.gnu.org/archive/html/bug-bash/2012-05/msg00010.html Which appears to correspond to the following from https://tiswww.case.edu/php/chet/bash/CHANGES: This document details the changes between this version, bash-4.3-alpha, and the previous version, bash-4.2-release. ggg. Fixed a bug that causes the shell to delete DEL characters in the expanded value of variables used in the same quoted string as variables that expand to nothing. This doesn't speak directly to the "single item array expansion to empty string" behavior difference, but the patch contains the following change to `expand_word_internal` from `subst.c`: *** ../bash-20120427/subst.c 2012-04-22 16:19:10.000000000 -0400 --- subst.c 2012-05-07 16:06:35.000000000 -0400 *************** *** 8152,8155 **** --- 8152,8163 ---- dispose_word_desc (tword); + /* Kill quoted nulls; we will add them back at the end of + expand_word_internal if nothing else in the string */ + if (had_quoted_null && temp && QUOTED_NULL (temp)) + { + FREE (temp); + temp = (char *)NULL; + } + goto add_string; break; `QUOTED_NULL` is defined in `subst.h` as: /* Is the first character of STRING a quoted NULL character? */ #define QUOTED_NULL(string) ((string)[0] == CTLNUL && (string)[1] == '\0') and `CTLNUL` is defined in `shell.h` as: #define CTLNUL '\177' Back to `expand_word_internal`, the `add_string:` label following the `Kill quoted nulls` block is followed by: add_string: if (temp) { istring = sub_append_string (...); temp = (char *)0; } break; Finally, the end of the function contains the following comment and block of code: ``` finished_with_string: /* ... */ /* If we expand to nothing and there were no single or double quotes in the word, we throw it away. Otherwise, we return a NULL word. The single exception is for $@ surrounded by double quotes when there are no positional parameters. In that case, we also throw the word away. */ if (*istring == '\0') { if (quoted_dollar_at == 0 && ...) { /* ... */ } /* According to sh, ksh, and Posix.2, if a word expands into nothing and a double-quoted "$@" appears anywhere in it, then the entire word is removed. */ else if (quoted_state == UNQUOTED || quoted_dollar_at) list = (WORD_LIST *)NULL; ``` As best I can tell, the 4.2.32 patch that kills `QUOTED_NULL` values prevented the assignment to `istring` (under the `add_string:` label) that was still performed as of 4.2.31. Somehow expanding a single-item array such that it results in the empty string produces a `QUOTED_NULL`, hence the difference in behavior betweeen the two versions. With this change to the test, the bugs in the tests themselves are fixed, and the tests all pass under both versions. That said, this is a rather pernicious kind of bug that seems difficult to detect and defend against, given that it only manifests when single-item arrays are expanded as a single empty string. The interface to `assert_success` and `assert_failure` isn't wrong; it's intended to make it easy to check command output without requiring two different assertions when a straight equality check will do—especially when there should be no output at all. I'll have to think on whether there's an effective mechanism or idiom that can guard against it; but for now, my guard is up, at least. --- tests/new.bats | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/new.bats b/tests/new.bats index 2f88d7f..b81fc81 100644 --- a/tests/new.bats +++ b/tests/new.bats @@ -90,7 +90,7 @@ assert_command_script_is_executable() { touch "${internal_modules[@]}" local expected - @go.test_compgen 'expected' -f "$TEST_GO_SCRIPTS_DIR/lib" + @go.test_compgen 'expected' -f "$TEST_GO_SCRIPTS_DIR/lib/" run "$TEST_GO_SCRIPT" complete 2 new '--internal' assert_success "${expected[@]#$TEST_GO_SCRIPTS_DIR/lib/}" @@ -112,7 +112,7 @@ assert_command_script_is_executable() { touch "${public_modules[@]}" local expected - @go.test_compgen 'expected' -f "$TEST_GO_ROOTDIR/lib" + @go.test_compgen 'expected' -f "$TEST_GO_ROOTDIR/lib/" run "$TEST_GO_SCRIPT" complete 2 new '--public' assert_success "${expected[@]#$TEST_GO_ROOTDIR/lib/}" @@ -134,7 +134,7 @@ assert_command_script_is_executable() { touch "${test_files[@]}" local expected - @go.test_compgen 'expected' -f "$TEST_GO_ROOTDIR/$_GO_TEST_DIR" + @go.test_compgen 'expected' -f "$TEST_GO_ROOTDIR/$_GO_TEST_DIR/" run "$TEST_GO_SCRIPT" complete 2 new '--test' assert_success "${expected[@]#$TEST_GO_ROOTDIR/$_GO_TEST_DIR/}" From f530a603252466db23ed659784b3fccd07130774 Mon Sep 17 00:00:00 2001 From: Mike Bland Date: Tue, 21 Mar 2017 11:06:50 -0400 Subject: [PATCH 8/8] bats/helpers: Create script before setting PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The following test case from `tests/new.bats` was failing on Windows (using the MSYS2 Bash 4.3.46(2)-release from Git for Windows): ✗ new: error if setting permissions fails (from function `stub_program_in_path' in file lib/bats/helpers, line 320, in test file tests/new.bats, line 268) `stub_program_in_path 'chmod' 'printf "ARG: %s\n" "$@"' 'exit 1'' failed ARG: 700 ARG: /tmp/test rootdir/bin/chmod ARG: -R ARG: u+rwx ARG: /tmp/test rootdir Apparently on this platform, `chmod` wasn't already `hash`-ed, and using `stub_program_in_path` to stub out `chmod` was causing `create_bats_test_script` to look up `chmod` in `PATH` andinvoke the stub script itself. The fix: call `create_bats_test_script` before setting `PATH`. The existing `stub_program_in_path for testing external program` test case from `tests/bats-helpers.bats` now uses `chmod` for its example program instead of `git`, which reproduced the original failure and verifies its fix. --- lib/bats/helpers | 2 +- tests/bats-helpers.bats | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/bats/helpers b/lib/bats/helpers index 7c3fad0..473e2b4 100644 --- a/lib/bats/helpers +++ b/lib/bats/helpers @@ -313,11 +313,11 @@ stub_program_in_path() { in_process='true' shift fi + create_bats_test_script "${BATS_TEST_BINDIR#$BATS_TEST_ROOTDIR/}/$1" "${@:2}" if [[ ! "$PATH" =~ $bindir_pattern ]]; then export PATH="$BATS_TEST_BINDIR:$PATH" fi - create_bats_test_script "${BATS_TEST_BINDIR#$BATS_TEST_ROOTDIR/}/$1" "${@:2}" if [[ -n "$in_process" ]]; then hash "$1" diff --git a/tests/bats-helpers.bats b/tests/bats-helpers.bats index d8a3988..2274f38 100644 --- a/tests/bats-helpers.bats +++ b/tests/bats-helpers.bats @@ -240,11 +240,11 @@ __check_dirs_exist() { local bats_bindir_pattern="^${BATS_TEST_BINDIR}:" fail_if matches "$bats_bindir_pattern" "$PATH" - stub_program_in_path 'git' 'echo "$@"' + stub_program_in_path 'chmod' 'echo "$@"' assert_matches "$bats_bindir_pattern" "$PATH" - run git Hello, World! - assert_success 'Hello, World!' + run chmod ugo+rwx foo.txt + assert_success 'ugo+rwx foo.txt' } @test "$SUITE: {stub,restore}_program_in_path for testing in-process function" {