diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index a5e9ccdc..6d14ee11 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -22,8 +22,6 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
- with:
- submodules: true
- name: Install uv
uses: astral-sh/setup-uv@v5
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index 24c78c2d..00000000
--- a/.gitmodules
+++ /dev/null
@@ -1,6 +0,0 @@
-[submodule "tests/helpers/bats-assert"]
- path = tests/helpers/bats-assert
- url = git@github.com:bats-core/bats-assert.git
-[submodule "tests/helpers/bats-support"]
- path = tests/helpers/bats-support
- url = git@github.com:bats-core/bats-support.git
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a6d0853d..e454d188 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,5 +1,8 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
+
+# Vendored third-party code (bats test helpers) — keep byte-identical to upstream
+exclude: ^tests/helpers/
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
diff --git a/tests/helpers/bats-assert b/tests/helpers/bats-assert
deleted file mode 160000
index 912a9880..00000000
--- a/tests/helpers/bats-assert
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 912a98804efd34f24d5eae1bf97ee622ca770e99
diff --git a/tests/helpers/bats-assert/LICENSE b/tests/helpers/bats-assert/LICENSE
new file mode 100644
index 00000000..670154e3
--- /dev/null
+++ b/tests/helpers/bats-assert/LICENSE
@@ -0,0 +1,116 @@
+CC0 1.0 Universal
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator and
+subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for the
+purpose of contributing to a commons of creative, cultural and scientific
+works ("Commons") that the public can reliably and without fear of later
+claims of infringement build upon, modify, incorporate in other works, reuse
+and redistribute as freely as possible in any form whatsoever and for any
+purposes, including without limitation commercial purposes. These owners may
+contribute to the Commons to promote the ideal of a free culture and the
+further production of creative, cultural and scientific works, or to gain
+reputation or greater distribution for their Work in part through the use and
+efforts of others.
+
+For these and/or other purposes and motivations, and without any expectation
+of additional consideration or compensation, the person associating CC0 with a
+Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
+and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
+and publicly distribute the Work under its terms, with knowledge of his or her
+Copyright and Related Rights in the Work and the meaning and intended legal
+effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not limited
+to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display, communicate,
+ and translate a Work;
+
+ ii. moral rights retained by the original author(s) and/or performer(s);
+
+ iii. publicity and privacy rights pertaining to a person's image or likeness
+ depicted in a Work;
+
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+
+ v. rights protecting the extraction, dissemination, use and reuse of data in
+ a Work;
+
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation thereof,
+ including any amended or successor version of such directive); and
+
+ vii. other similar, equivalent or corresponding rights throughout the world
+ based on applicable law or treaty, and any national implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention of,
+applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
+unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
+and Related Rights and associated claims and causes of action, whether now
+known or unknown (including existing as well as future claims and causes of
+action), in the Work (i) in all territories worldwide, (ii) for the maximum
+duration provided by applicable law or treaty (including future time
+extensions), (iii) in any current or future medium and for any number of
+copies, and (iv) for any purpose whatsoever, including without limitation
+commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
+the Waiver for the benefit of each member of the public at large and to the
+detriment of Affirmer's heirs and successors, fully intending that such Waiver
+shall not be subject to revocation, rescission, cancellation, termination, or
+any other legal or equitable action to disrupt the quiet enjoyment of the Work
+by the public as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason be
+judged legally invalid or ineffective under applicable law, then the Waiver
+shall be preserved to the maximum extent permitted taking into account
+Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
+is so judged Affirmer hereby grants to each affected person a royalty-free,
+non transferable, non sublicensable, non exclusive, irrevocable and
+unconditional license to exercise Affirmer's Copyright and Related Rights in
+the Work (i) in all territories worldwide, (ii) for the maximum duration
+provided by applicable law or treaty (including future time extensions), (iii)
+in any current or future medium and for any number of copies, and (iv) for any
+purpose whatsoever, including without limitation commercial, advertising or
+promotional purposes (the "License"). The License shall be deemed effective as
+of the date CC0 was applied by Affirmer to the Work. Should any part of the
+License for any reason be judged legally invalid or ineffective under
+applicable law, such partial invalidity or ineffectiveness shall not
+invalidate the remainder of the License, and in such case Affirmer hereby
+affirms that he or she will not (i) exercise any of his or her remaining
+Copyright and Related Rights in the Work or (ii) assert any associated claims
+and causes of action with respect to the Work, in either case contrary to
+Affirmer's express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+
+ b. Affirmer offers the Work as-is and makes no representations or warranties
+ of any kind concerning the Work, express, implied, statutory or otherwise,
+ including without limitation warranties of title, merchantability, fitness
+ for a particular purpose, non infringement, or the absence of latent or
+ other defects, accuracy, or the present or absence of errors, whether or not
+ discoverable, all to the greatest extent permissible under applicable law.
+
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without limitation
+ any person's Copyright and Related Rights in the Work. Further, Affirmer
+ disclaims responsibility for obtaining any necessary consents, permissions
+ or other rights required for any use of the Work.
+
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to this
+ CC0 or use of the Work.
+
+For more information, please see
+
diff --git a/tests/helpers/bats-assert/load.bash b/tests/helpers/bats-assert/load.bash
new file mode 100644
index 00000000..c67d9e8b
--- /dev/null
+++ b/tests/helpers/bats-assert/load.bash
@@ -0,0 +1,33 @@
+# bats-assert - Common assertions for Bats
+#
+# Written in 2016 by Zoltan Tombol
+#
+# To the extent possible under law, the author(s) have dedicated all
+# copyright and related and neighboring rights to this software to the
+# public domain worldwide. This software is distributed without any
+# warranty.
+#
+# You should have received a copy of the CC0 Public Domain Dedication
+# along with this software. If not, see
+# .
+#
+# Assertions are functions that perform a test and output relevant
+# information on failure to help debugging. They return 1 on failure
+# and 0 otherwise.
+#
+# All output is formatted for readability using the functions of
+# `output.bash' and sent to the standard error.
+
+# shellcheck disable=1090
+source "$(dirname "${BASH_SOURCE[0]}")/src/assert.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/refute.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/assert_equal.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/assert_not_equal.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/assert_success.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/assert_failure.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/assert_output.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/refute_output.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/assert_line.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/refute_line.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/assert_regex.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/refute_regex.bash"
diff --git a/tests/helpers/bats-assert/src/assert.bash b/tests/helpers/bats-assert/src/assert.bash
new file mode 100644
index 00000000..0260ce23
--- /dev/null
+++ b/tests/helpers/bats-assert/src/assert.bash
@@ -0,0 +1,42 @@
+# assert
+# ======
+#
+# Summary: Fail if the given expression evaluates to false.
+#
+# Usage: assert
+
+# Options:
+# The expression to evaluate for truthiness.
+# *__Note:__ The expression must be a simple command.
+# [Compound commands](https://www.gnu.org/software/bash/manual/bash.html#Compound-Commands),
+# such as `[[`, can be used only when executed with `bash -c`.*
+#
+# IO:
+# STDERR - the failed expression, on failure
+# Globals:
+# none
+# Returns:
+# 0 - if expression evaluates to true
+# 1 - otherwise
+#
+# ```bash
+# @test 'assert()' {
+# touch '/var/log/test.log'
+# assert [ -e '/var/log/test.log' ]
+# }
+# ```
+#
+# On failure, the failed expression is displayed.
+#
+# ```
+# -- assertion failed --
+# expression : [ -e /var/log/test.log ]
+# --
+# ```
+assert() {
+ if ! "$@"; then
+ batslib_print_kv_single 10 'expression' "$*" \
+ | batslib_decorate 'assertion failed' \
+ | fail
+ fi
+}
diff --git a/tests/helpers/bats-assert/src/assert_equal.bash b/tests/helpers/bats-assert/src/assert_equal.bash
new file mode 100644
index 00000000..4ef1297e
--- /dev/null
+++ b/tests/helpers/bats-assert/src/assert_equal.bash
@@ -0,0 +1,42 @@
+# assert_equal
+# ============
+#
+# Summary: Fail if the actual and expected values are not equal.
+#
+# Usage: assert_equal
+#
+# Options:
+# The value being compared.
+# The value to compare against.
+#
+# ```bash
+# @test 'assert_equal()' {
+# assert_equal 'have' 'want'
+# }
+# ```
+#
+# IO:
+# STDERR - expected and actual values, on failure
+# Globals:
+# none
+# Returns:
+# 0 - if values equal
+# 1 - otherwise
+#
+# On failure, the expected and actual values are displayed.
+#
+# ```
+# -- values do not equal --
+# expected : want
+# actual : have
+# --
+# ```
+assert_equal() {
+ if [[ $1 != "$2" ]]; then
+ batslib_print_kv_single_or_multi 8 \
+ 'expected' "$2" \
+ 'actual' "$1" \
+ | batslib_decorate 'values do not equal' \
+ | fail
+ fi
+}
diff --git a/tests/helpers/bats-assert/src/assert_failure.bash b/tests/helpers/bats-assert/src/assert_failure.bash
new file mode 100644
index 00000000..268d4475
--- /dev/null
+++ b/tests/helpers/bats-assert/src/assert_failure.bash
@@ -0,0 +1,86 @@
+# assert_failure
+# ==============
+#
+# Summary: Fail if `$status` is 0; or is not equal to the optionally provided status.
+#
+# Usage: assert_failure []
+#
+# Options:
+# The specific status code to check against.
+# If not provided, simply asserts status is != 0.
+#
+# IO:
+# STDERR - `$output`, on failure;
+# - also, `$status` and `expected_status`, if provided
+# Globals:
+# status
+# output
+# Returns:
+# 0 - if `$status' is 0,
+# or if expected_status is provided but does not equal `$status'
+# 1 - otherwise
+#
+# ```bash
+# @test 'assert_failure() status only' {
+# run echo 'Success!'
+# assert_failure
+# }
+# ```
+#
+# On failure, `$output` is displayed.
+#
+# ```
+# -- command succeeded, but it was expected to fail --
+# output : Success!
+# --
+# ```
+#
+# ## Expected status
+#
+# When `expected_status` is provided, fail if `$status` does not equal the `expected_status`.
+#
+# ```bash
+# @test 'assert_failure() with expected status' {
+# run bash -c "echo 'Error!'; exit 1"
+# assert_failure 2
+# }
+# ```
+#
+# On failure, both the expected and actual statuses, and `$output` are displayed.
+#
+# ```
+# -- command failed as expected, but status differs --
+# expected : 2
+# actual : 1
+# output : Error!
+# --
+# ```
+assert_failure() {
+ : "${output?}"
+ : "${status?}"
+
+ (( $# > 0 )) && local -r expected="$1"
+ if (( status == 0 )); then
+ { local -ir width=6
+ batslib_print_kv_single_or_multi "$width" 'output' "$output"
+ if [[ -n "${stderr-}" ]]; then
+ batslib_print_kv_single_or_multi "$width" 'stderr' "$stderr"
+ fi
+ } \
+ | batslib_decorate 'command succeeded, but it was expected to fail' \
+ | fail
+ elif (( $# > 0 )) && (( status != expected )); then
+ { local -ir width=8
+ batslib_print_kv_single "$width" \
+ 'expected' "$expected" \
+ 'actual' "$status"
+ batslib_print_kv_single_or_multi "$width" \
+ 'output' "$output"
+ if [[ -n "${stderr-}" ]]; then
+ batslib_print_kv_single_or_multi "$width" 'stderr' "$stderr"
+ fi
+ } \
+ | batslib_decorate 'command failed as expected, but status differs' \
+ | fail
+ fi
+}
diff --git a/tests/helpers/bats-assert/src/assert_line.bash b/tests/helpers/bats-assert/src/assert_line.bash
new file mode 100644
index 00000000..bf9140d5
--- /dev/null
+++ b/tests/helpers/bats-assert/src/assert_line.bash
@@ -0,0 +1,301 @@
+# assert_line
+# ===========
+#
+# Summary: Fail if the expected line is not found in the output (default) or at a specific line number.
+#
+# Usage: assert_line [-n index] [-p | -e] [--]
+#
+# Options:
+# -n, --index Match the th line
+# -p, --partial Match if `expected` is a substring of `$output` or line
+# -e, --regexp Treat `expected` as an extended regular expression
+# The expected line string, substring, or regular expression
+#
+# IO:
+# STDERR - details, on failure
+# error message, on error
+# Globals:
+# output
+# lines
+# Returns:
+# 0 - if matching line found
+# 1 - otherwise
+#
+# Similarly to `assert_output`, this function verifies that a command or function produces the expected output.
+# (It is the logical complement of `refute_line`.)
+# It checks that the expected line appears in the output (default) or at a specific line number.
+# Matching can be literal (default), partial or regular expression.
+#
+# *__Warning:__
+# Due to a [bug in Bats][bats-93], empty lines are discarded from `${lines[@]}`,
+# causing line indices to change and preventing testing for empty lines.*
+#
+# [bats-93]: https://github.com/sstephenson/bats/pull/93
+#
+# ## Looking for a line in the output
+#
+# By default, the entire output is searched for the expected line.
+# The assertion fails if the expected line is not found in `${lines[@]}`.
+#
+# ```bash
+# @test 'assert_line() looking for line' {
+# run echo $'have-0\nhave-1\nhave-2'
+# assert_line 'want'
+# }
+# ```
+#
+# On failure, the expected line and the output are displayed.
+#
+# ```
+# -- output does not contain line --
+# line : want
+# output (3 lines):
+# have-0
+# have-1
+# have-2
+# --
+# ```
+#
+# ## Matching a specific line
+#
+# When the `--index ` option is used (`-n ` for short), the expected line is matched only against the line identified by the given index.
+# The assertion fails if the expected line does not equal `${lines[]}`.
+#
+# ```bash
+# @test 'assert_line() specific line' {
+# run echo $'have-0\nhave-1\nhave-2'
+# assert_line --index 1 'want-1'
+# }
+# ```
+#
+# On failure, the index and the compared lines are displayed.
+#
+# ```
+# -- line differs --
+# index : 1
+# expected : want-1
+# actual : have-1
+# --
+# ```
+#
+# ## Partial matching
+#
+# Partial matching can be enabled with the `--partial` option (`-p` for short).
+# When used, a match fails if the expected *substring* is not found in the matched line.
+#
+# ```bash
+# @test 'assert_line() partial matching' {
+# run echo $'have 1\nhave 2\nhave 3'
+# assert_line --partial 'want'
+# }
+# ```
+#
+# On failure, the same details are displayed as for literal matching, except that the substring replaces the expected line.
+#
+# ```
+# -- no output line contains substring --
+# substring : want
+# output (3 lines):
+# have 1
+# have 2
+# have 3
+# --
+# ```
+#
+# ## Regular expression matching
+#
+# Regular expression matching can be enabled with the `--regexp` option (`-e` for short).
+# When used, a match fails if the *extended regular expression* does not match the line being tested.
+#
+# *__Note__:
+# As expected, the anchors `^` and `$` bind to the beginning and the end (respectively) of the matched line.*
+#
+# ```bash
+# @test 'assert_line() regular expression matching' {
+# run echo $'have-0\nhave-1\nhave-2'
+# assert_line --index 1 --regexp '^want-[0-9]$'
+# }
+# ```
+#
+# On failure, the same details are displayed as for literal matching, except that the regular expression replaces the expected line.
+#
+# ```
+# -- regular expression does not match line --
+# index : 1
+# regexp : ^want-[0-9]$
+# line : have-1
+# --
+# ```
+# FIXME(ztombol): Display `${lines[@]}' instead of `$output'!
+assert_line() {
+ __assert_line "$@"
+}
+
+# assert_stderr_line
+# ===========
+#
+# Summary: Fail if the expected line is not found in the stderr (default) or at a specific line number.
+#
+# Usage: assert_stderr_line [-n index] [-p | -e] [--]
+#
+# Options:
+# -n, --index Match the th line
+# -p, --partial Match if `expected` is a substring of `$stderr` or line
+# -e, --regexp Treat `expected` as an extended regular expression
+# The expected line string, substring, or regular expression
+#
+# IO:
+# STDERR - details, on failure
+# error message, on error
+# Globals:
+# stderr
+# stderr_lines
+# Returns:
+# 0 - if matching line found
+# 1 - otherwise
+#
+# Similarly to `assert_stderr`, this function verifies that a command or function produces the expected stderr.
+# (It is the logical complement of `refute_stderr_line`.)
+# It checks that the expected line appears in the stderr (default) or at a specific line number.
+# Matching can be literal (default), partial or regular expression.
+#
+assert_stderr_line() {
+ __assert_line "$@"
+}
+
+__assert_line() {
+ local -r caller=${FUNCNAME[1]}
+ local -i is_match_line=0
+ local -i is_mode_partial=0
+ local -i is_mode_regexp=0
+
+ if [[ "${caller}" == "assert_line" ]]; then
+ : "${lines?}"
+ local -ar stream_lines=("${lines[@]}")
+ local -r stream_type=output
+ elif [[ "${caller}" == "assert_stderr_line" ]]; then
+ : "${stderr_lines?}"
+ local -ar stream_lines=("${stderr_lines[@]}")
+ local -r stream_type=stderr
+ else
+ # Unknown caller
+ echo "Unexpected call to \`${FUNCNAME[0]}\`
+Did you mean to call \`assert_line\` or \`assert_stderr_line\`?" \
+ | batslib_decorate "ERROR: ${FUNCNAME[0]}" \
+ | fail
+ return $?
+ fi
+
+ # Handle options.
+ while (( $# > 0 )); do
+ case "$1" in
+ -n|--index)
+ if (( $# < 2 )) || ! [[ $2 =~ ^-?([0-9]|[1-9][0-9]+)$ ]]; then
+ echo "\`--index' requires an integer argument: \`$2'" \
+ | batslib_decorate "ERROR: ${caller}" \
+ | fail
+ return $?
+ fi
+ is_match_line=1
+ local -ri idx="$2"
+ shift 2
+ ;;
+ -p|--partial) is_mode_partial=1; shift ;;
+ -e|--regexp) is_mode_regexp=1; shift ;;
+ --) shift; break ;;
+ *) break ;;
+ esac
+ done
+
+ if (( is_mode_partial )) && (( is_mode_regexp )); then
+ echo "\`--partial' and \`--regexp' are mutually exclusive" \
+ | batslib_decorate "ERROR: ${caller}" \
+ | fail
+ return $?
+ fi
+
+ # Arguments.
+ local -r expected="$1"
+
+ if (( is_mode_regexp == 1 )) && [[ '' =~ $expected ]] || (( $? == 2 )); then
+ echo "Invalid extended regular expression: \`$expected'" \
+ | batslib_decorate "ERROR: ${caller}" \
+ | fail
+ return $?
+ fi
+
+ # Matching.
+ if (( is_match_line )); then
+ # Specific line.
+ if (( is_mode_regexp )); then
+ if ! [[ ${stream_lines[$idx]} =~ $expected ]]; then
+ batslib_print_kv_single 6 \
+ 'index' "$idx" \
+ 'regexp' "$expected" \
+ 'line' "${stream_lines[$idx]}" \
+ | batslib_decorate 'regular expression does not match line' \
+ | fail
+ fi
+ elif (( is_mode_partial )); then
+ if [[ ${stream_lines[$idx]} != *"$expected"* ]]; then
+ batslib_print_kv_single 9 \
+ 'index' "$idx" \
+ 'substring' "$expected" \
+ 'line' "${stream_lines[$idx]}" \
+ | batslib_decorate 'line does not contain substring' \
+ | fail
+ fi
+ else
+ if [[ ${stream_lines[$idx]} != "$expected" ]]; then
+ batslib_print_kv_single 8 \
+ 'index' "$idx" \
+ 'expected' "$expected" \
+ 'actual' "${stream_lines[$idx]}" \
+ | batslib_decorate 'line differs' \
+ | fail
+ fi
+ fi
+ else
+ # Contained in output/error stream.
+ if (( is_mode_regexp )); then
+ local -i idx
+ for (( idx = 0; idx < ${#stream_lines[@]}; ++idx )); do
+ [[ ${stream_lines[$idx]} =~ $expected ]] && return 0
+ done
+ { local -ar single=( 'regexp' "$expected" )
+ local -ar may_be_multi=( "${stream_type}" "${!stream_type}" )
+ local -ir width="$( batslib_get_max_single_line_key_width "${single[@]}" "${may_be_multi[@]}" )"
+ batslib_print_kv_single "$width" "${single[@]}"
+ batslib_print_kv_single_or_multi "$width" "${may_be_multi[@]}"
+ } \
+ | batslib_decorate "no ${stream_type} line matches regular expression" \
+ | fail
+ elif (( is_mode_partial )); then
+ local -i idx
+ for (( idx = 0; idx < ${#stream_lines[@]}; ++idx )); do
+ [[ ${stream_lines[$idx]} == *"$expected"* ]] && return 0
+ done
+ { local -ar single=( 'substring' "$expected" )
+ local -ar may_be_multi=( "${stream_type}" "${!stream_type}" )
+ local -ir width="$( batslib_get_max_single_line_key_width "${single[@]}" "${may_be_multi[@]}" )"
+ batslib_print_kv_single "$width" "${single[@]}"
+ batslib_print_kv_single_or_multi "$width" "${may_be_multi[@]}"
+ } \
+ | batslib_decorate "no ${stream_type} line contains substring" \
+ | fail
+ else
+ local -i idx
+ for (( idx = 0; idx < ${#stream_lines[@]}; ++idx )); do
+ [[ ${stream_lines[$idx]} == "$expected" ]] && return 0
+ done
+ { local -ar single=( 'line' "$expected" )
+ local -ar may_be_multi=( "${stream_type}" "${!stream_type}" )
+ local -ir width="$( batslib_get_max_single_line_key_width "${single[@]}" "${may_be_multi[@]}" )"
+ batslib_print_kv_single "$width" "${single[@]}"
+ batslib_print_kv_single_or_multi "$width" "${may_be_multi[@]}"
+ } \
+ | batslib_decorate "${stream_type} does not contain line" \
+ | fail
+ fi
+ fi
+}
diff --git a/tests/helpers/bats-assert/src/assert_not_equal.bash b/tests/helpers/bats-assert/src/assert_not_equal.bash
new file mode 100644
index 00000000..933bb713
--- /dev/null
+++ b/tests/helpers/bats-assert/src/assert_not_equal.bash
@@ -0,0 +1,42 @@
+# assert_not_equal
+# ============
+#
+# Summary: Fail if the actual and unexpected values are equal.
+#
+# Usage: assert_not_equal
+#
+# Options:
+# The value being compared.
+# The value to compare against.
+#
+# ```bash
+# @test 'assert_not_equal()' {
+# assert_not_equal 'foo' 'foo'
+# }
+# ```
+#
+# IO:
+# STDERR - expected and actual values, on failure
+# Globals:
+# none
+# Returns:
+# 0 - if actual does not equal unexpected
+# 1 - otherwise
+#
+# On failure, the unexpected and actual values are displayed.
+#
+# ```
+# -- values should not be equal --
+# unexpected : foo
+# actual : foo
+# --
+# ```
+assert_not_equal() {
+ if [[ "$1" == "$2" ]]; then
+ batslib_print_kv_single_or_multi 10 \
+ 'unexpected' "$2" \
+ 'actual' "$1" \
+ | batslib_decorate 'values should not be equal' \
+ | fail
+ fi
+}
diff --git a/tests/helpers/bats-assert/src/assert_output.bash b/tests/helpers/bats-assert/src/assert_output.bash
new file mode 100644
index 00000000..168d2464
--- /dev/null
+++ b/tests/helpers/bats-assert/src/assert_output.bash
@@ -0,0 +1,249 @@
+# assert_output
+# =============
+#
+# Summary: Fail if `$output' does not match the expected output.
+#
+# Usage: assert_output [-p | -e] [- | [--] ]
+#
+# Options:
+# -p, --partial Match if `expected` is a substring of `$output`
+# -e, --regexp Treat `expected` as an extended regular expression
+# -, --stdin Read `expected` value from STDIN
+# The expected value, substring or regular expression
+#
+# IO:
+# STDIN - [=$1] expected output
+# STDERR - details, on failure
+# error message, on error
+# Globals:
+# output
+# Returns:
+# 0 - if output matches the expected value/partial/regexp
+# 1 - otherwise
+#
+# This function verifies that a command or function produces the expected output.
+# (It is the logical complement of `refute_output`.)
+# Output matching can be literal (the default), partial or by regular expression.
+# The expected output can be specified either by positional argument or read from STDIN by passing the `-`/`--stdin` flag.
+#
+# ## Literal matching
+#
+# By default, literal matching is performed.
+# The assertion fails if `$output` does not equal the expected output.
+#
+# ```bash
+# @test 'assert_output()' {
+# run echo 'have'
+# assert_output 'want'
+# }
+#
+# @test 'assert_output() with pipe' {
+# run echo 'hello'
+# echo 'hello' | assert_output -
+# }
+#
+# @test 'assert_output() with herestring' {
+# run echo 'hello'
+# assert_output - <<< hello
+# }
+# ```
+#
+# On failure, the expected and actual output are displayed.
+#
+# ```
+# -- output differs --
+# expected : want
+# actual : have
+# --
+# ```
+#
+# ## Existence
+#
+# To assert that any output exists at all, omit the `expected` argument.
+#
+# ```bash
+# @test 'assert_output()' {
+# run echo 'have'
+# assert_output
+# }
+# ```
+#
+# On failure, an error message is displayed.
+#
+# ```
+# -- no output --
+# expected non-empty output, but output was empty
+# --
+# ```
+#
+# ## Partial matching
+#
+# Partial matching can be enabled with the `--partial` option (`-p` for short).
+# When used, the assertion fails if the expected _substring_ is not found in `$output`.
+#
+# ```bash
+# @test 'assert_output() partial matching' {
+# run echo 'ERROR: no such file or directory'
+# assert_output --partial 'SUCCESS'
+# }
+# ```
+#
+# On failure, the substring and the output are displayed.
+#
+# ```
+# -- output does not contain substring --
+# substring : SUCCESS
+# output : ERROR: no such file or directory
+# --
+# ```
+#
+# ## Regular expression matching
+#
+# Regular expression matching can be enabled with the `--regexp` option (`-e` for short).
+# When used, the assertion fails if the *extended regular expression* does not match `$output`.
+#
+# *__Note__:
+# The anchors `^` and `$` bind to the beginning and the end (respectively) of the entire output;
+# not individual lines.*
+#
+# ```bash
+# @test 'assert_output() regular expression matching' {
+# run echo 'Foobar 0.1.0'
+# assert_output --regexp '^Foobar v[0-9]+\.[0-9]+\.[0-9]$'
+# }
+# ```
+#
+# On failure, the regular expression and the output are displayed.
+#
+# ```
+# -- regular expression does not match output --
+# regexp : ^Foobar v[0-9]+\.[0-9]+\.[0-9]$
+# output : Foobar 0.1.0
+# --
+# ```
+assert_output() {
+ __assert_stream "$@"
+}
+
+# assert_stderr
+# =============
+#
+# Summary: Fail if `$stderr' does not match the expected stderr.
+#
+# Usage: assert_stderr [-p | -e] [- | [--] ]
+#
+# Options:
+# -p, --partial Match if `expected` is a substring of `$stderr`
+# -e, --regexp Treat `expected` as an extended regular expression
+# -, --stdin Read `expected` value from STDIN
+# The expected value, substring or regular expression
+#
+# IO:
+# STDIN - [=$1] expected stderr
+# STDERR - details, on failure
+# error message, on error
+# Globals:
+# stderr
+# Returns:
+# 0 - if stderr matches the expected value/partial/regexp
+# 1 - otherwise
+#
+# Similarly to `assert_output`, this function verifies that a command or function produces the expected stderr.
+# (It is the logical complement of `refute_stderr`.)
+# The stderr matching can be literal (the default), partial or by regular expression.
+# The expected stderr can be specified either by positional argument or read from STDIN by passing the `-`/`--stdin` flag.
+#
+assert_stderr() {
+ __assert_stream "$@"
+}
+
+__assert_stream() {
+ local -r caller=${FUNCNAME[1]}
+ local -r stream_type=${caller/assert_/}
+ local -i is_mode_partial=0
+ local -i is_mode_regexp=0
+ local -i is_mode_nonempty=0
+ local -i use_stdin=0
+
+ if [[ ${stream_type} == "output" ]]; then
+ : "${output?}"
+ elif [[ ${stream_type} == "stderr" ]]; then
+ : "${stderr?}"
+ else
+ # Unknown caller
+ echo "Unexpected call to \`${FUNCNAME[0]}\`
+Did you mean to call \`assert_output\` or \`assert_stderr\`?" |
+ batslib_decorate "ERROR: ${FUNCNAME[0]}" |
+ fail
+ return $?
+ fi
+ local -r stream="${!stream_type}"
+
+ # Handle options.
+ if (( $# == 0 )); then
+ is_mode_nonempty=1
+ fi
+
+ while (( $# > 0 )); do
+ case "$1" in
+ -p|--partial) is_mode_partial=1; shift ;;
+ -e|--regexp) is_mode_regexp=1; shift ;;
+ -|--stdin) use_stdin=1; shift ;;
+ --) shift; break ;;
+ *) break ;;
+ esac
+ done
+
+ if (( is_mode_partial )) && (( is_mode_regexp )); then
+ echo "\`--partial' and \`--regexp' are mutually exclusive" \
+ | batslib_decorate "ERROR: ${caller}" \
+ | fail
+ return $?
+ fi
+
+ # Arguments.
+ local expected
+ if (( use_stdin )); then
+ expected="$(cat -)"
+ else
+ expected="${1-}"
+ fi
+
+ # Matching.
+ if (( is_mode_nonempty )); then
+ if [ -z "$stream" ]; then
+ echo "expected non-empty $stream_type, but $stream_type was empty" \
+ | batslib_decorate "no $stream_type" \
+ | fail
+ fi
+ elif (( is_mode_regexp )); then
+ # shellcheck disable=2319
+ if [[ '' =~ $expected ]] || (( $? == 2 )); then
+ echo "Invalid extended regular expression: \`$expected'" \
+ | batslib_decorate "ERROR: ${caller}" \
+ | fail
+ elif ! [[ $stream =~ $expected ]]; then
+ batslib_print_kv_single_or_multi 6 \
+ 'regexp' "$expected" \
+ "$stream_type" "$stream" \
+ | batslib_decorate "regular expression does not match $stream_type" \
+ | fail
+ fi
+ elif (( is_mode_partial )); then
+ if [[ $stream != *"$expected"* ]]; then
+ batslib_print_kv_single_or_multi 9 \
+ 'substring' "$expected" \
+ "$stream_type" "$stream" \
+ | batslib_decorate "$stream_type does not contain substring" \
+ | fail
+ fi
+ else
+ if [[ $stream != "$expected" ]]; then
+ batslib_print_kv_single_or_multi 8 \
+ 'expected' "$expected" \
+ 'actual' "$stream" \
+ | batslib_decorate "$stream_type differs" \
+ | fail
+ fi
+ fi
+}
diff --git a/tests/helpers/bats-assert/src/assert_regex.bash b/tests/helpers/bats-assert/src/assert_regex.bash
new file mode 100644
index 00000000..17a70572
--- /dev/null
+++ b/tests/helpers/bats-assert/src/assert_regex.bash
@@ -0,0 +1,56 @@
+# `assert_regex`
+#
+# This function is similar to `assert_equal` but uses pattern matching instead
+# of equality, by wrapping `[[ value =~ pattern ]]`.
+#
+# Fail if the value (first parameter) does not match the pattern (second
+# parameter).
+#
+# ```bash
+# @test 'assert_regex()' {
+# assert_regex 'what' 'x$'
+# }
+# ```
+#
+# On failure, the value and the pattern are displayed.
+#
+# ```
+# -- values does not match regular expression --
+# value : what
+# pattern : x$
+# --
+# ```
+#
+# If the value is longer than one line then it is displayed in *multi-line*
+# format.
+#
+# An error is displayed if the specified extended regular expression is invalid.
+#
+# For description of the matching behavior, refer to the documentation of the
+# `=~` operator in the
+# [Bash manual]: https://www.gnu.org/software/bash/manual/html_node/Conditional-Constructs.html.
+# Note that the `BASH_REMATCH` array is available immediately after the
+# assertion succeeds but is fragile, i.e. prone to being overwritten as a side
+# effect of other actions.
+assert_regex() {
+ local -r value="${1}"
+ local -r pattern="${2}"
+
+ if [[ '' =~ ${pattern} ]]; (( ${?} == 2 )); then
+ echo "Invalid extended regular expression: \`${pattern}'" \
+ | batslib_decorate 'ERROR: assert_regex' \
+ | fail
+ elif ! [[ "${value}" =~ ${pattern} ]]; then
+ if shopt -p nocasematch &>/dev/null; then
+ local case_sensitive=insensitive
+ else
+ local case_sensitive=sensitive
+ fi
+ batslib_print_kv_single_or_multi 8 \
+ 'value' "${value}" \
+ 'pattern' "${pattern}" \
+ 'case' "${case_sensitive}" \
+ | batslib_decorate 'value does not match regular expression' \
+ | fail
+ fi
+}
diff --git a/tests/helpers/bats-assert/src/assert_success.bash b/tests/helpers/bats-assert/src/assert_success.bash
new file mode 100644
index 00000000..e3f074d2
--- /dev/null
+++ b/tests/helpers/bats-assert/src/assert_success.bash
@@ -0,0 +1,47 @@
+# assert_success
+# ==============
+#
+# Summary: Fail if `$status` is not 0.
+#
+# Usage: assert_success
+#
+# IO:
+# STDERR - `$status` and `$output`, on failure
+# Globals:
+# status
+# output
+# Returns:
+# 0 - if `$status' is 0
+# 1 - otherwise
+#
+# ```bash
+# @test 'assert_success() status only' {
+# run bash -c "echo 'Error!'; exit 1"
+# assert_success
+# }
+# ```
+#
+# On failure, `$status` and `$output` are displayed.
+#
+# ```
+# -- command failed --
+# status : 1
+# output : Error!
+# --
+# ```
+assert_success() {
+ : "${output?}"
+ : "${status?}"
+
+ if (( status != 0 )); then
+ { local -ir width=6
+ batslib_print_kv_single "$width" 'status' "$status"
+ batslib_print_kv_single_or_multi "$width" 'output' "$output"
+ if [[ -n "${stderr-}" ]]; then
+ batslib_print_kv_single_or_multi "$width" 'stderr' "$stderr"
+ fi
+ } \
+ | batslib_decorate 'command failed' \
+ | fail
+ fi
+}
diff --git a/tests/helpers/bats-assert/src/refute.bash b/tests/helpers/bats-assert/src/refute.bash
new file mode 100644
index 00000000..e7c47da8
--- /dev/null
+++ b/tests/helpers/bats-assert/src/refute.bash
@@ -0,0 +1,42 @@
+# refute
+# ======
+#
+# Summary: Fail if the given expression evaluates to true.
+#
+# Usage: refute
+#
+# Options:
+# The expression to evaluate for falsiness.
+# *__Note:__ The expression must be a simple command.
+# [Compound commands](https://www.gnu.org/software/bash/manual/bash.html#Compound-Commands),
+# such as `[[`, can be used only when executed with `bash -c`.*
+#
+# IO:
+# STDERR - the successful expression, on failure
+# Globals:
+# none
+# Returns:
+# 0 - if expression evaluates to false
+# 1 - otherwise
+#
+# ```bash
+# @test 'refute()' {
+# rm -f '/var/log/test.log'
+# refute [ -e '/var/log/test.log' ]
+# }
+# ```
+#
+# On failure, the successful expression is displayed.
+#
+# ```
+# -- assertion succeeded, but it was expected to fail --
+# expression : [ -e /var/log/test.log ]
+# --
+# ```
+refute() {
+ if "$@"; then
+ batslib_print_kv_single 10 'expression' "$*" \
+ | batslib_decorate 'assertion succeeded, but it was expected to fail' \
+ | fail
+ fi
+}
diff --git a/tests/helpers/bats-assert/src/refute_line.bash b/tests/helpers/bats-assert/src/refute_line.bash
new file mode 100644
index 00000000..bb7337d0
--- /dev/null
+++ b/tests/helpers/bats-assert/src/refute_line.bash
@@ -0,0 +1,318 @@
+# refute_line
+# ===========
+#
+# Summary: Fail if the unexpected line is found in the output (default) or at a specific line number.
+#
+# Usage: refute_line [-n index] [-p | -e] [--]
+#
+# Options:
+# -n, --index Match the th line
+# -p, --partial Match if `unexpected` is a substring of `$output` or line
+# -e, --regexp Treat `unexpected` as an extended regular expression
+# The unexpected line string, substring, or regular expression.
+#
+# IO:
+# STDERR - details, on failure
+# error message, on error
+# Globals:
+# output
+# lines
+# Returns:
+# 0 - if match not found
+# 1 - otherwise
+#
+# Similarly to `refute_output`, this function verifies that a command or function does not produce the unexpected output.
+# (It is the logical complement of `assert_line`.)
+# It checks that the unexpected line does not appear in the output (default) or at a specific line number.
+# Matching can be literal (default), partial or regular expression.
+#
+# ## Looking for a line in the output
+#
+# By default, the entire output is searched for the unexpected line.
+# The assertion fails if the unexpected line is found in `${lines[@]}`.
+#
+# ```bash
+# @test 'refute_line() looking for line' {
+# run echo $'have-0\nwant\nhave-2'
+# refute_line 'want'
+# }
+# ```
+#
+# On failure, the unexpected line, the index of its first match and the output with the matching line highlighted are displayed.
+#
+# ```
+# -- line should not be in output --
+# line : want
+# index : 1
+# output (3 lines):
+# have-0
+# > want
+# have-2
+# --
+# ```
+#
+# ## Matching a specific line
+#
+# When the `--index ` option is used (`-n ` for short), the unexpected line is matched only against the line identified by the given index.
+# The assertion fails if the unexpected line equals `${lines[]}`.
+#
+# ```bash
+# @test 'refute_line() specific line' {
+# run echo $'have-0\nwant-1\nhave-2'
+# refute_line --index 1 'want-1'
+# }
+# ```
+#
+# On failure, the index and the unexpected line are displayed.
+#
+# ```
+# -- line should differ --
+# index : 1
+# line : want-1
+# --
+# ```
+#
+# ## Partial matching
+#
+# Partial matching can be enabled with the `--partial` option (`-p` for short).
+# When used, a match fails if the unexpected *substring* is found in the matched line.
+#
+# ```bash
+# @test 'refute_line() partial matching' {
+# run echo $'have 1\nwant 2\nhave 3'
+# refute_line --partial 'want'
+# }
+# ```
+#
+# On failure, in addition to the details of literal matching, the substring is also displayed.
+# When used with `--index ` the substring replaces the unexpected line.
+#
+# ```
+# -- no line should contain substring --
+# substring : want
+# index : 1
+# output (3 lines):
+# have 1
+# > want 2
+# have 3
+# --
+# ```
+#
+# ## Regular expression matching
+#
+# Regular expression matching can be enabled with the `--regexp` option (`-e` for short).
+# When used, a match fails if the *extended regular expression* matches the line being tested.
+#
+# *__Note__:
+# As expected, the anchors `^` and `$` bind to the beginning and the end (respectively) of the matched line.*
+#
+# ```bash
+# @test 'refute_line() regular expression matching' {
+# run echo $'Foobar v0.1.0\nRelease date: 2015-11-29'
+# refute_line --index 0 --regexp '^Foobar v[0-9]+\.[0-9]+\.[0-9]$'
+# }
+# ```
+#
+# On failure, in addition to the details of literal matching, the regular expression is also displayed.
+# When used with `--index ` the regular expression replaces the unexpected line.
+#
+# ```
+# -- regular expression should not match line --
+# index : 0
+# regexp : ^Foobar v[0-9]+\.[0-9]+\.[0-9]$
+# line : Foobar v0.1.0
+# --
+# ```
+# FIXME(ztombol): Display `${lines[@]}' instead of `$output'!
+refute_line() {
+ __refute_stream_line "$@"
+}
+
+# refute_stderr_line
+# ==================
+#
+# Summary: Fail if the unexpected line is found in the stderr (default) or at a specific line number.
+#
+# Usage: refute_stderr_line [-n index] [-p | -e] [--]
+#
+# Options:
+# -n, --index Match the th line
+# -p, --partial Match if `unexpected` is a substring of `$stderr` or line
+# -e, --regexp Treat `unexpected` as an extended regular expression
+# The unexpected line string, substring, or regular expression.
+#
+# IO:
+# STDERR - details, on failure
+# error message, on error
+# Globals:
+# stderr
+# stderr_lines
+# Returns:
+# 0 - if match not found
+# 1 - otherwise
+#
+# Similarly to `refute_stderr`, this function verifies that a command or function does not produce the unexpected stderr.
+# (It is the logical complement of `assert_stderr_line`.)
+# It checks that the unexpected line does not appear in the stderr (default) or at a specific line number.
+# Matching can be literal (default), partial or regular expression.
+#
+refute_stderr_line() {
+ __refute_stream_line "$@"
+}
+
+__refute_stream_line() {
+ local -r caller=${FUNCNAME[1]}
+ local -i is_match_line=0
+ local -i is_mode_partial=0
+ local -i is_mode_regexp=0
+
+ if [[ "${caller}" == "refute_line" ]]; then
+ : "${lines?}"
+ local -ar stream_lines=("${lines[@]}")
+ local -r stream_type=output
+ elif [[ "${caller}" == "refute_stderr_line" ]]; then
+ : "${stderr_lines?}"
+ local -ar stream_lines=("${stderr_lines[@]}")
+ local -r stream_type=stderr
+ else
+ # Unknown caller
+ echo "Unexpected call to \`${FUNCNAME[0]}\`
+Did you mean to call \`refute_line\` or \`refute_stderr_line\`?" |
+ batslib_decorate "ERROR: ${FUNCNAME[0]}" |
+ fail
+ return $?
+ fi
+
+ # Handle options.
+ while (( $# > 0 )); do
+ case "$1" in
+ -n|--index)
+ if (( $# < 2 )) || ! [[ $2 =~ ^-?([0-9]|[1-9][0-9]+)$ ]]; then
+ echo "\`--index' requires an integer argument: \`$2'" \
+ | batslib_decorate "ERROR: ${caller}" \
+ | fail
+ return $?
+ fi
+ is_match_line=1
+ local -ri idx="$2"
+ shift 2
+ ;;
+ -p|--partial) is_mode_partial=1; shift ;;
+ -e|--regexp) is_mode_regexp=1; shift ;;
+ --) shift; break ;;
+ *) break ;;
+ esac
+ done
+
+ if (( is_mode_partial )) && (( is_mode_regexp )); then
+ echo "\`--partial' and \`--regexp' are mutually exclusive" \
+ | batslib_decorate "ERROR: ${caller}" \
+ | fail
+ return $?
+ fi
+
+ # Arguments.
+ local -r unexpected="$1"
+
+ if (( is_mode_regexp == 1 )) && [[ '' =~ $unexpected ]] || (( $? == 2 )); then
+ echo "Invalid extended regular expression: \`$unexpected'" \
+ | batslib_decorate "ERROR: ${caller}" \
+ | fail
+ return $?
+ fi
+
+ # Matching.
+ if (( is_match_line )); then
+ # Specific line.
+ if (( is_mode_regexp )); then
+ if [[ ${stream_lines[$idx]} =~ $unexpected ]]; then
+ batslib_print_kv_single 6 \
+ 'index' "$idx" \
+ 'regexp' "$unexpected" \
+ 'line' "${stream_lines[$idx]}" \
+ | batslib_decorate 'regular expression should not match line' \
+ | fail
+ fi
+ elif (( is_mode_partial )); then
+ if [[ ${stream_lines[$idx]} == *"$unexpected"* ]]; then
+ batslib_print_kv_single 9 \
+ 'index' "$idx" \
+ 'substring' "$unexpected" \
+ 'line' "${stream_lines[$idx]}" \
+ | batslib_decorate 'line should not contain substring' \
+ | fail
+ fi
+ else
+ if [[ ${stream_lines[$idx]} == "$unexpected" ]]; then
+ batslib_print_kv_single 5 \
+ 'index' "$idx" \
+ 'line' "${stream_lines[$idx]}" \
+ | batslib_decorate 'line should differ' \
+ | fail
+ fi
+ fi
+ else
+ # Line contained in output/error stream.
+ if (( is_mode_regexp )); then
+ local -i idx
+ for (( idx = 0; idx < ${#stream_lines[@]}; ++idx )); do
+ if [[ ${stream_lines[$idx]} =~ $unexpected ]]; then
+ { local -ar single=( 'regexp' "$unexpected" 'index' "$idx" )
+ local -a may_be_multi=( "${stream_type}" "${!stream_type}" )
+ local -ir width="$( batslib_get_max_single_line_key_width "${single[@]}" "${may_be_multi[@]}" )"
+ batslib_print_kv_single "$width" "${single[@]}"
+ if batslib_is_single_line "${may_be_multi[1]}"; then
+ batslib_print_kv_single "$width" "${may_be_multi[@]}"
+ else
+ may_be_multi[1]="$( printf '%s' "${may_be_multi[1]}" | batslib_prefix | batslib_mark '>' "$idx" )"
+ batslib_print_kv_multi "${may_be_multi[@]}"
+ fi
+ } \
+ | batslib_decorate 'no line should match the regular expression' \
+ | fail
+ return $?
+ fi
+ done
+ elif (( is_mode_partial )); then
+ local -i idx
+ for (( idx = 0; idx < ${#stream_lines[@]}; ++idx )); do
+ if [[ ${stream_lines[$idx]} == *"$unexpected"* ]]; then
+ { local -ar single=( 'substring' "$unexpected" 'index' "$idx" )
+ local -a may_be_multi=( "${stream_type}" "${!stream_type}" )
+ local -ir width="$( batslib_get_max_single_line_key_width "${single[@]}" "${may_be_multi[@]}" )"
+ batslib_print_kv_single "$width" "${single[@]}"
+ if batslib_is_single_line "${may_be_multi[1]}"; then
+ batslib_print_kv_single "$width" "${may_be_multi[@]}"
+ else
+ may_be_multi[1]="$( printf '%s' "${may_be_multi[1]}" | batslib_prefix | batslib_mark '>' "$idx" )"
+ batslib_print_kv_multi "${may_be_multi[@]}"
+ fi
+ } \
+ | batslib_decorate 'no line should contain substring' \
+ | fail
+ return $?
+ fi
+ done
+ else
+ local -i idx
+ for (( idx = 0; idx < ${#stream_lines[@]}; ++idx )); do
+ if [[ ${stream_lines[$idx]} == "$unexpected" ]]; then
+ { local -ar single=( 'line' "$unexpected" 'index' "$idx" )
+ local -a may_be_multi=( "${stream_type}" "${!stream_type}" )
+ local -ir width="$( batslib_get_max_single_line_key_width "${single[@]}" "${may_be_multi[@]}" )"
+ batslib_print_kv_single "$width" "${single[@]}"
+ if batslib_is_single_line "${may_be_multi[1]}"; then
+ batslib_print_kv_single "$width" "${may_be_multi[@]}"
+ else
+ may_be_multi[1]="$( printf '%s' "${may_be_multi[1]}" | batslib_prefix | batslib_mark '>' "$idx" )"
+ batslib_print_kv_multi "${may_be_multi[@]}"
+ fi
+ } \
+ | batslib_decorate "line should not be in ${stream_type}" \
+ | fail
+ return $?
+ fi
+ done
+ fi
+ fi
+}
diff --git a/tests/helpers/bats-assert/src/refute_output.bash b/tests/helpers/bats-assert/src/refute_output.bash
new file mode 100644
index 00000000..d656515a
--- /dev/null
+++ b/tests/helpers/bats-assert/src/refute_output.bash
@@ -0,0 +1,246 @@
+# refute_output
+# =============
+#
+# Summary: Fail if `$output' matches the unexpected output.
+#
+# Usage: refute_output [-p | -e] [- | [--] ]
+#
+# Options:
+# -p, --partial Match if `unexpected` is a substring of `$output`
+# -e, --regexp Treat `unexpected` as an extended regular expression
+# -, --stdin Read `unexpected` value from STDIN
+# The unexpected value, substring, or regular expression
+#
+# IO:
+# STDIN - [=$1] unexpected output
+# STDERR - details, on failure
+# error message, on error
+# Globals:
+# output
+# Returns:
+# 0 - if output matches the unexpected value/partial/regexp
+# 1 - otherwise
+#
+# This function verifies that a command or function does not produce the unexpected output.
+# (It is the logical complement of `assert_output`.)
+# Output matching can be literal (the default), partial or by regular expression.
+# The unexpected output can be specified either by positional argument or read from STDIN by passing the `-`/`--stdin` flag.
+#
+# ## Literal matching
+#
+# By default, literal matching is performed.
+# The assertion fails if `$output` equals the unexpected output.
+#
+# ```bash
+# @test 'refute_output()' {
+# run echo 'want'
+# refute_output 'want'
+# }
+#
+# @test 'refute_output() with pipe' {
+# run echo 'hello'
+# echo 'world' | refute_output -
+# }
+#
+# @test 'refute_output() with herestring' {
+# run echo 'hello'
+# refute_output - <<< world
+# }
+# ```
+#
+# On failure, the output is displayed.
+#
+# ```
+# -- output equals, but it was expected to differ --
+# output : want
+# --
+# ```
+#
+# ## Existence
+#
+# To assert that there is no output at all, omit the matching argument.
+#
+# ```bash
+# @test 'refute_output()' {
+# run foo --silent
+# refute_output
+# }
+# ```
+#
+# On failure, an error message is displayed.
+#
+# ```
+# -- unexpected output --
+# expected no output, but output was non-empty
+# --
+# ```
+#
+# ## Partial matching
+#
+# Partial matching can be enabled with the `--partial` option (`-p` for short).
+# When used, the assertion fails if the unexpected _substring_ is found in `$output`.
+#
+# ```bash
+# @test 'refute_output() partial matching' {
+# run echo 'ERROR: no such file or directory'
+# refute_output --partial 'ERROR'
+# }
+# ```
+#
+# On failure, the substring and the output are displayed.
+#
+# ```
+# -- output should not contain substring --
+# substring : ERROR
+# output : ERROR: no such file or directory
+# --
+# ```
+#
+# ## Regular expression matching
+#
+# Regular expression matching can be enabled with the `--regexp` option (`-e` for short).
+# When used, the assertion fails if the *extended regular expression* matches `$output`.
+#
+# *__Note__:
+# The anchors `^` and `$` bind to the beginning and the end (respectively) of the entire output;
+# not individual lines.*
+#
+# ```bash
+# @test 'refute_output() regular expression matching' {
+# run echo 'Foobar v0.1.0'
+# refute_output --regexp '^Foobar v[0-9]+\.[0-9]+\.[0-9]$'
+# }
+# ```
+#
+# On failure, the regular expression and the output are displayed.
+#
+# ```
+# -- regular expression should not match output --
+# regexp : ^Foobar v[0-9]+\.[0-9]+\.[0-9]$
+# output : Foobar v0.1.0
+# --
+# ```
+refute_output() {
+ __refute_stream "$@"
+}
+
+# refute_stderr
+# =============
+#
+# Summary: Fail if `$stderr' matches the unexpected output.
+#
+# Usage: refute_stderr [-p | -e] [- | [--] ]
+#
+# Options:
+# -p, --partial Match if `unexpected` is a substring of `$stderr`
+# -e, --regexp Treat `unexpected` as an extended regular expression
+# -, --stdin Read `unexpected` value from STDIN
+# The unexpected value, substring, or regular expression
+#
+# IO:
+# STDIN - [=$1] unexpected stderr
+# STDERR - details, on failure
+# error message, on error
+# Globals:
+# stderr
+# Returns:
+# 0 - if stderr matches the unexpected value/partial/regexp
+# 1 - otherwise
+#
+# Similar to `refute_output`, this function verifies that a command or function does not produce the unexpected stderr.
+# (It is the logical complement of `assert_stderr`.)
+# The stderr matching can be literal (the default), partial or by regular expression.
+# The unexpected stderr can be specified either by positional argument or read from STDIN by passing the `-`/`--stdin` flag.
+#
+refute_stderr() {
+ __refute_stream "$@"
+}
+
+__refute_stream() {
+ local -r caller=${FUNCNAME[1]}
+ local -r stream_type=${caller/refute_/}
+ local -i is_mode_partial=0
+ local -i is_mode_regexp=0
+ local -i is_mode_empty=0
+ local -i use_stdin=0
+
+ if [[ ${stream_type} == "output" ]]; then
+ : "${output?}"
+ elif [[ ${stream_type} == "stderr" ]]; then
+ : "${stderr?}"
+ else
+ # Not reachable: should be either output or stderr
+ :
+ fi
+ local -r stream="${!stream_type}"
+
+ # Handle options.
+ if (( $# == 0 )); then
+ is_mode_empty=1
+ fi
+
+ while (( $# > 0 )); do
+ case "$1" in
+ -p|--partial) is_mode_partial=1; shift ;;
+ -e|--regexp) is_mode_regexp=1; shift ;;
+ -|--stdin) use_stdin=1; shift ;;
+ --) shift; break ;;
+ *) break ;;
+ esac
+ done
+
+ if (( is_mode_partial )) && (( is_mode_regexp )); then
+ echo "\`--partial' and \`--regexp' are mutually exclusive" \
+ | batslib_decorate "ERROR: ${caller}" \
+ | fail
+ return $?
+ fi
+
+ # Arguments.
+ local unexpected
+ if (( use_stdin )); then
+ unexpected="$(cat -)"
+ else
+ unexpected="${1-}"
+ fi
+
+ if (( is_mode_regexp == 1 )) && [[ '' =~ $unexpected ]] || (( $? == 2 )); then
+ echo "Invalid extended regular expression: \`$unexpected'" \
+ | batslib_decorate "ERROR: ${caller}" \
+ | fail
+ return $?
+ fi
+
+ # Matching.
+ if (( is_mode_empty )); then
+ if [ -n "${stream}" ]; then
+ batslib_print_kv_single_or_multi 6 \
+ "${stream_type}" "${stream}" \
+ | batslib_decorate "${stream_type} non-empty, but expected no ${stream_type}" \
+ | fail
+ fi
+ elif (( is_mode_regexp )); then
+ if [[ ${stream} =~ $unexpected ]]; then
+ batslib_print_kv_single_or_multi 6 \
+ 'regexp' "$unexpected" \
+ "${stream_type}" "${stream}" \
+ | batslib_decorate "regular expression should not match ${stream_type}" \
+ | fail
+ fi
+ elif (( is_mode_partial )); then
+ if [[ ${stream} == *"$unexpected"* ]]; then
+ batslib_print_kv_single_or_multi 9 \
+ 'substring' "$unexpected" \
+ "${stream_type}" "${stream}" \
+ | batslib_decorate "${stream_type} should not contain substring" \
+ | fail
+ fi
+ else
+ if [[ ${stream} == "$unexpected" ]]; then
+ batslib_print_kv_single_or_multi 6 \
+ "${stream_type}" "${stream}" \
+ | batslib_decorate "${stream_type} equals, but it was expected to differ" \
+ | fail
+ fi
+ fi
+}
diff --git a/tests/helpers/bats-assert/src/refute_regex.bash b/tests/helpers/bats-assert/src/refute_regex.bash
new file mode 100644
index 00000000..0918793f
--- /dev/null
+++ b/tests/helpers/bats-assert/src/refute_regex.bash
@@ -0,0 +1,66 @@
+# `refute_regex`
+#
+# This function is similar to `refute_equal` but uses pattern matching instead
+# of equality, by wrapping `! [[ value =~ pattern ]]`.
+#
+# Fail if the value (first parameter) matches the pattern (second parameter).
+#
+# ```bash
+# @test 'refute_regex()' {
+# refute_regex 'WhatsApp' 'Threema'
+# }
+# ```
+#
+# On failure, the value, the pattern and the match are displayed.
+#
+# ```
+# @test 'refute_regex()' {
+# refute_regex 'WhatsApp' 'What.'
+# }
+#
+# -- value matches regular expression --
+# value : WhatsApp
+# pattern : What.
+# match : Whats
+# case : sensitive
+# --
+# ```
+#
+# If the value or pattern is longer than one line then it is displayed in
+# *multi-line* format.
+#
+# An error is displayed if the specified extended regular expression is invalid.
+#
+# For description of the matching behavior, refer to the documentation of the
+# `=~` operator in the
+# [Bash manual]: https://www.gnu.org/software/bash/manual/html_node/Conditional-Constructs.html.
+#
+# Note that the `BASH_REMATCH` array is available immediately after the
+# assertion fails but is fragile, i.e. prone to being overwritten as a side
+# effect of other actions like calling `run`. Thus, it's good practice to avoid
+# using `BASH_REMATCH` in conjunction with `refute_regex()`. The valuable
+# information the array contains is the matching part of the value which is
+# printed in the failing test log, as mentioned above.
+refute_regex() {
+ local -r value="${1}"
+ local -r pattern="${2}"
+
+ if [[ '' =~ ${pattern} ]] || (( ${?} == 2 )); then
+ echo "Invalid extended regular expression: \`${pattern}'" \
+ | batslib_decorate 'ERROR: refute_regex' \
+ | fail
+ elif [[ "${value}" =~ ${pattern} ]]; then
+ if shopt -p nocasematch &>/dev/null; then
+ local case_sensitive=insensitive
+ else
+ local case_sensitive=sensitive
+ fi
+ batslib_print_kv_single_or_multi 8 \
+ 'value' "${value}" \
+ 'pattern' "${pattern}" \
+ 'match' "${BASH_REMATCH[0]}" \
+ 'case' "${case_sensitive}" \
+ | batslib_decorate 'value matches regular expression' \
+ | fail
+ fi
+}
diff --git a/tests/helpers/bats-support b/tests/helpers/bats-support
deleted file mode 160000
index 0ad082d4..00000000
--- a/tests/helpers/bats-support
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 0ad082d4590108684c68975ca517a90459f05cd0
diff --git a/tests/helpers/bats-support/LICENSE b/tests/helpers/bats-support/LICENSE
new file mode 100644
index 00000000..670154e3
--- /dev/null
+++ b/tests/helpers/bats-support/LICENSE
@@ -0,0 +1,116 @@
+CC0 1.0 Universal
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator and
+subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for the
+purpose of contributing to a commons of creative, cultural and scientific
+works ("Commons") that the public can reliably and without fear of later
+claims of infringement build upon, modify, incorporate in other works, reuse
+and redistribute as freely as possible in any form whatsoever and for any
+purposes, including without limitation commercial purposes. These owners may
+contribute to the Commons to promote the ideal of a free culture and the
+further production of creative, cultural and scientific works, or to gain
+reputation or greater distribution for their Work in part through the use and
+efforts of others.
+
+For these and/or other purposes and motivations, and without any expectation
+of additional consideration or compensation, the person associating CC0 with a
+Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
+and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
+and publicly distribute the Work under its terms, with knowledge of his or her
+Copyright and Related Rights in the Work and the meaning and intended legal
+effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not limited
+to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display, communicate,
+ and translate a Work;
+
+ ii. moral rights retained by the original author(s) and/or performer(s);
+
+ iii. publicity and privacy rights pertaining to a person's image or likeness
+ depicted in a Work;
+
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+
+ v. rights protecting the extraction, dissemination, use and reuse of data in
+ a Work;
+
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation thereof,
+ including any amended or successor version of such directive); and
+
+ vii. other similar, equivalent or corresponding rights throughout the world
+ based on applicable law or treaty, and any national implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention of,
+applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
+unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
+and Related Rights and associated claims and causes of action, whether now
+known or unknown (including existing as well as future claims and causes of
+action), in the Work (i) in all territories worldwide, (ii) for the maximum
+duration provided by applicable law or treaty (including future time
+extensions), (iii) in any current or future medium and for any number of
+copies, and (iv) for any purpose whatsoever, including without limitation
+commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
+the Waiver for the benefit of each member of the public at large and to the
+detriment of Affirmer's heirs and successors, fully intending that such Waiver
+shall not be subject to revocation, rescission, cancellation, termination, or
+any other legal or equitable action to disrupt the quiet enjoyment of the Work
+by the public as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason be
+judged legally invalid or ineffective under applicable law, then the Waiver
+shall be preserved to the maximum extent permitted taking into account
+Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
+is so judged Affirmer hereby grants to each affected person a royalty-free,
+non transferable, non sublicensable, non exclusive, irrevocable and
+unconditional license to exercise Affirmer's Copyright and Related Rights in
+the Work (i) in all territories worldwide, (ii) for the maximum duration
+provided by applicable law or treaty (including future time extensions), (iii)
+in any current or future medium and for any number of copies, and (iv) for any
+purpose whatsoever, including without limitation commercial, advertising or
+promotional purposes (the "License"). The License shall be deemed effective as
+of the date CC0 was applied by Affirmer to the Work. Should any part of the
+License for any reason be judged legally invalid or ineffective under
+applicable law, such partial invalidity or ineffectiveness shall not
+invalidate the remainder of the License, and in such case Affirmer hereby
+affirms that he or she will not (i) exercise any of his or her remaining
+Copyright and Related Rights in the Work or (ii) assert any associated claims
+and causes of action with respect to the Work, in either case contrary to
+Affirmer's express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+
+ b. Affirmer offers the Work as-is and makes no representations or warranties
+ of any kind concerning the Work, express, implied, statutory or otherwise,
+ including without limitation warranties of title, merchantability, fitness
+ for a particular purpose, non infringement, or the absence of latent or
+ other defects, accuracy, or the present or absence of errors, whether or not
+ discoverable, all to the greatest extent permissible under applicable law.
+
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without limitation
+ any person's Copyright and Related Rights in the Work. Further, Affirmer
+ disclaims responsibility for obtaining any necessary consents, permissions
+ or other rights required for any use of the Work.
+
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to this
+ CC0 or use of the Work.
+
+For more information, please see
+
diff --git a/tests/helpers/bats-support/load.bash b/tests/helpers/bats-support/load.bash
new file mode 100644
index 00000000..3f52722e
--- /dev/null
+++ b/tests/helpers/bats-support/load.bash
@@ -0,0 +1,11 @@
+# Preserve path at the time this file was sourced
+# This prevents using of user-defined mocks/stubs that modify the PATH
+
+# BATS_SAVED_PATH was introduced in bats-core v1.10.0
+# if it is already set, we can use its more robust value
+# else we try to recreate it here
+BATS_SAVED_PATH="${BATS_SAVED_PATH-$PATH}"
+
+source "$(dirname "${BASH_SOURCE[0]}")/src/output.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/error.bash"
+source "$(dirname "${BASH_SOURCE[0]}")/src/lang.bash"
diff --git a/tests/helpers/bats-support/src/error.bash b/tests/helpers/bats-support/src/error.bash
new file mode 100644
index 00000000..e5d97912
--- /dev/null
+++ b/tests/helpers/bats-support/src/error.bash
@@ -0,0 +1,41 @@
+#
+# bats-support - Supporting library for Bats test helpers
+#
+# Written in 2016 by Zoltan Tombol
+#
+# To the extent possible under law, the author(s) have dedicated all
+# copyright and related and neighboring rights to this software to the
+# public domain worldwide. This software is distributed without any
+# warranty.
+#
+# You should have received a copy of the CC0 Public Domain Dedication
+# along with this software. If not, see
+# .
+#
+
+#
+# error.bash
+# ----------
+#
+# Functions implementing error reporting. Used by public helper
+# functions or test suits directly.
+#
+
+# Fail and display a message. When no parameters are specified, the
+# message is read from the standard input. Other functions use this to
+# report failure.
+#
+# Globals:
+# none
+# Arguments:
+# $@ - [=STDIN] message
+# Returns:
+# 1 - always
+# Inputs:
+# STDIN - [=$@] message
+# Outputs:
+# STDERR - message
+fail() {
+ (( $# == 0 )) && batslib_err || batslib_err "$@"
+ return 1
+}
diff --git a/tests/helpers/bats-support/src/lang.bash b/tests/helpers/bats-support/src/lang.bash
new file mode 100644
index 00000000..c57e299c
--- /dev/null
+++ b/tests/helpers/bats-support/src/lang.bash
@@ -0,0 +1,73 @@
+#
+# bats-util - Various auxiliary functions for Bats
+#
+# Written in 2016 by Zoltan Tombol
+#
+# To the extent possible under law, the author(s) have dedicated all
+# copyright and related and neighboring rights to this software to the
+# public domain worldwide. This software is distributed without any
+# warranty.
+#
+# You should have received a copy of the CC0 Public Domain Dedication
+# along with this software. If not, see
+# .
+#
+
+#
+# lang.bash
+# ---------
+#
+# Bash language and execution related functions. Used by public helper
+# functions.
+#
+
+# Check whether the calling function was called from a given function.
+#
+# By default, direct invocation is checked. The function succeeds if the
+# calling function was called directly from the given function. In other
+# words, if the given function is the next element on the call stack.
+#
+# When `--indirect' is specified, indirect invocation is checked. The
+# function succeeds if the calling function was called from the given
+# function with any number of intermediate calls. In other words, if the
+# given function can be found somewhere on the call stack.
+#
+# Direct invocation is a form of indirect invocation with zero
+# intermediate calls.
+#
+# Globals:
+# FUNCNAME
+# Options:
+# -i, --indirect - check indirect invocation
+# Arguments:
+# $1 - calling function's name
+# Returns:
+# 0 - current function was called from the given function
+# 1 - otherwise
+batslib_is_caller() {
+ local -i is_mode_direct=1
+
+ # Handle options.
+ while (( $# > 0 )); do
+ case "$1" in
+ -i|--indirect) is_mode_direct=0; shift ;;
+ --) shift; break ;;
+ *) break ;;
+ esac
+ done
+
+ # Arguments.
+ local -r func="$1"
+
+ # Check call stack.
+ if (( is_mode_direct )); then
+ [[ $func == "${FUNCNAME[2]}" ]] && return 0
+ else
+ local -i depth
+ for (( depth=2; depth<${#FUNCNAME[@]}; ++depth )); do
+ [[ $func == "${FUNCNAME[$depth]}" ]] && return 0
+ done
+ fi
+
+ return 1
+}
diff --git a/tests/helpers/bats-support/src/output.bash b/tests/helpers/bats-support/src/output.bash
new file mode 100644
index 00000000..10ca8ae5
--- /dev/null
+++ b/tests/helpers/bats-support/src/output.bash
@@ -0,0 +1,279 @@
+#
+# bats-support - Supporting library for Bats test helpers
+#
+# Written in 2016 by Zoltan Tombol
+#
+# To the extent possible under law, the author(s) have dedicated all
+# copyright and related and neighboring rights to this software to the
+# public domain worldwide. This software is distributed without any
+# warranty.
+#
+# You should have received a copy of the CC0 Public Domain Dedication
+# along with this software. If not, see
+# .
+#
+
+#
+# output.bash
+# -----------
+#
+# Private functions implementing output formatting. Used by public
+# helper functions.
+#
+
+# Print a message to the standard error. When no parameters are
+# specified, the message is read from the standard input.
+#
+# Globals:
+# none
+# Arguments:
+# $@ - [=STDIN] message
+# Returns:
+# none
+# Inputs:
+# STDIN - [=$@] message
+# Outputs:
+# STDERR - message
+batslib_err() {
+ { if (( $# > 0 )); then
+ echo "$@"
+ else
+ PATH="$BATS_SAVED_PATH" command cat -
+ fi
+ } >&2
+}
+
+# Count the number of lines in the given string.
+#
+# TODO(ztombol): Fix tests and remove this note after #93 is resolved!
+# NOTE: Due to a bug in Bats, `batslib_count_lines "$output"' does not
+# give the same result as `${#lines[@]}' when the output contains
+# empty lines.
+# See PR #93 (https://github.com/sstephenson/bats/pull/93).
+#
+# Globals:
+# none
+# Arguments:
+# $1 - string
+# Returns:
+# none
+# Outputs:
+# STDOUT - number of lines
+batslib_count_lines() {
+ local -i n_lines=0
+ local line
+ while IFS='' read -r line || [[ -n $line ]]; do
+ (( ++n_lines ))
+ done < <(printf '%s' "$1")
+ echo "$n_lines"
+}
+
+# Determine whether all strings are single-line.
+#
+# Globals:
+# none
+# Arguments:
+# $@ - strings
+# Returns:
+# 0 - all strings are single-line
+# 1 - otherwise
+batslib_is_single_line() {
+ for string in "$@"; do
+ (( $(batslib_count_lines "$string") > 1 )) && return 1
+ done
+ return 0
+}
+
+# Determine the length of the longest key that has a single-line value.
+#
+# This function is useful in determining the correct width of the key
+# column in two-column format when some keys may have multi-line values
+# and thus should be excluded.
+#
+# Globals:
+# none
+# Arguments:
+# $odd - key
+# $even - value of the previous key
+# Returns:
+# none
+# Outputs:
+# STDOUT - length of longest key
+batslib_get_max_single_line_key_width() {
+ local -i max_len=-1
+ while (( $# != 0 )); do
+ local -i key_len="${#1}"
+ batslib_is_single_line "$2" && (( key_len > max_len )) && max_len="$key_len"
+ shift 2
+ done
+ echo "$max_len"
+}
+
+# Print key-value pairs in two-column format.
+#
+# Keys are displayed in the first column, and their corresponding values
+# in the second. To evenly line up values, the key column is fixed-width
+# and its width is specified with the first parameter (possibly computed
+# using `batslib_get_max_single_line_key_width').
+#
+# Globals:
+# none
+# Arguments:
+# $1 - width of key column
+# $even - key
+# $odd - value of the previous key
+# Returns:
+# none
+# Outputs:
+# STDOUT - formatted key-value pairs
+batslib_print_kv_single() {
+ local -ir col_width="$1"; shift
+ while (( $# != 0 )); do
+ printf '%-*s : %s\n' "$col_width" "$1" "$2"
+ shift 2
+ done
+}
+
+# Print key-value pairs in multi-line format.
+#
+# The key is displayed first with the number of lines of its
+# corresponding value in parenthesis. Next, starting on the next line,
+# the value is displayed. For better readability, it is recommended to
+# indent values using `batslib_prefix'.
+#
+# Globals:
+# none
+# Arguments:
+# $odd - key
+# $even - value of the previous key
+# Returns:
+# none
+# Outputs:
+# STDOUT - formatted key-value pairs
+batslib_print_kv_multi() {
+ while (( $# != 0 )); do
+ printf '%s (%d lines):\n' "$1" "$( batslib_count_lines "$2" )"
+ printf '%s\n' "$2"
+ shift 2
+ done
+}
+
+# Print all key-value pairs in either two-column or multi-line format
+# depending on whether all values are single-line.
+#
+# If all values are single-line, print all pairs in two-column format
+# with the specified key column width (identical to using
+# `batslib_print_kv_single').
+#
+# Otherwise, print all pairs in multi-line format after indenting values
+# with two spaces for readability (identical to using `batslib_prefix'
+# and `batslib_print_kv_multi')
+#
+# Globals:
+# none
+# Arguments:
+# $1 - width of key column (for two-column format)
+# $even - key
+# $odd - value of the previous key
+# Returns:
+# none
+# Outputs:
+# STDOUT - formatted key-value pairs
+batslib_print_kv_single_or_multi() {
+ local -ir width="$1"; shift
+ local -a pairs=( "$@" )
+
+ local -a values=()
+ local -i i
+ for (( i=1; i < ${#pairs[@]}; i+=2 )); do
+ values+=( "${pairs[$i]}" )
+ done
+
+ if batslib_is_single_line "${values[@]}"; then
+ batslib_print_kv_single "$width" "${pairs[@]}"
+ else
+ local -i i
+ for (( i=1; i < ${#pairs[@]}; i+=2 )); do
+ pairs[$i]="$( batslib_prefix < <(printf '%s' "${pairs[$i]}") )"
+ done
+ batslib_print_kv_multi "${pairs[@]}"
+ fi
+}
+
+# Prefix each line read from the standard input with the given string.
+#
+# Globals:
+# none
+# Arguments:
+# $1 - [= ] prefix string
+# Returns:
+# none
+# Inputs:
+# STDIN - lines
+# Outputs:
+# STDOUT - prefixed lines
+batslib_prefix() {
+ local -r prefix="${1:- }"
+ local line
+ while IFS='' read -r line || [[ -n $line ]]; do
+ printf '%s%s\n' "$prefix" "$line"
+ done
+}
+
+# Mark select lines of the text read from the standard input by
+# overwriting their beginning with the given string.
+#
+# Usually the input is indented by a few spaces using `batslib_prefix'
+# first.
+#
+# Globals:
+# none
+# Arguments:
+# $1 - marking string
+# $@ - indices (zero-based) of lines to mark
+# Returns:
+# none
+# Inputs:
+# STDIN - lines
+# Outputs:
+# STDOUT - lines after marking
+batslib_mark() {
+ local -r symbol="$1"; shift
+ # Sort line numbers.
+ set -- $( sort -nu <<< "$( printf '%d\n' "$@" )" )
+
+ local line
+ local -i idx=0
+ while IFS='' read -r line || [[ -n $line ]]; do
+ if (( ${1:--1} == idx )); then
+ printf '%s\n' "${symbol}${line:${#symbol}}"
+ shift
+ else
+ printf '%s\n' "$line"
+ fi
+ (( ++idx ))
+ done
+}
+
+# Enclose the input text in header and footer lines.
+#
+# The header contains the given string as title. The output is preceded
+# and followed by an additional newline to make it stand out more.
+#
+# Globals:
+# none
+# Arguments:
+# $1 - title
+# Returns:
+# none
+# Inputs:
+# STDIN - text
+# Outputs:
+# STDOUT - decorated text
+batslib_decorate() {
+ echo
+ echo "-- $1 --"
+ PATH="$BATS_SAVED_PATH" command cat -
+ echo '--'
+ echo
+}