Initial commit
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..0e259d4
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ef9dc0d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,115 @@
+# bats-core
+
+`bats-core` is a supporting library providing common functions to test
+helper libraries written for [Bats][bats].
+
+Features:
+- [output formatting](#output-formatting)
+
+See the [shared documentation][bats-docs] to learn how to install and
+load this library.
+
+If you want to use this library in your own helpers or just want to
+learn about its internals see the developer documentation in the [source
+files](src).
+
+
+## Output formatting
+
+Many test helpers need to produce human readable output. This library
+provides a simple way to format simple messages and key value pairs, and
+display them on the standard error.
+
+
+### Simple message
+
+Simple messages without structure, e.g. one-line error messages, are
+simply wrapped in a header and a footer to help them stand out.
+
+```
+-- ERROR: assert_output --
+`--partial' and `--regexp' are mutually exclusive
+--
+```
+
+
+### Key-Value pairs
+
+Some helpers, e.g. [assertions][bats-assert], structure output as
+key-value pairs. This library provides two ways to format them.
+
+When the value is one line long, a pair can be displayed in a columnar
+fashion called ***two-column*** format.
+
+```
+-- output differs --
+expected : want
+actual   : have
+--
+```
+
+When the value is longer than one line, the key and value must be
+displayed on separate lines. First, the key is displayed along with the
+number of lines in the value. Then, the value, indented by two spaces
+for added readability, starting on the next line. This is called
+***multi-line*** format.
+
+```
+-- command failed --
+status : 1
+output (2 lines):
+  Error! Something went terribly wrong!
+  Our engineers are panicing... \`>`;/
+--
+```
+
+Sometimes, for clarity, it is a good idea to display related values also
+in this format, even if they are just one line long.
+
+```
+-- output differs --
+expected (1 lines):
+  want
+actual (3 lines):
+  have 1
+  have 2
+  have 3
+--
+```
+
+
+# Licence
+
+`bats-core` is licensed under the CC0 licence to ensure the highest
+possible compatibility with other software.
+
+- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+<!-- BEGIN GENERATED with https://creativecommons.org/choose/zero/ -->
+
+<p xmlns:dct="http://purl.org/dc/terms/" xmlns:vcard="http://www.w3.org/2001/vcard-rdf/3.0#">
+  <a rel="license"
+     href="http://creativecommons.org/publicdomain/zero/1.0/">
+    <img src="http://i.creativecommons.org/p/zero/1.0/88x31.png" style="border-style: none;" alt="CC0" />
+  </a>
+  <br />
+  To the extent possible under law,
+  <a rel="dct:publisher"
+     href="https://github.com/ztombol">
+    <span property="dct:title">Zoltán Tömböl</span></a>
+  has waived all copyright and related or neighboring rights to
+  <span property="dct:title">bats-core</span>.
+This work is published from:
+<span property="vcard:Country" datatype="dct:ISO3166"
+      content="HU" about="https://github.com/ztombol">
+  Hungary</span>.
+</p>
+
+<!-- END GENERATED -->
+
+
+<!-- REFERENCES -->
+
+[bats]: https://github.com/sstephenson/bats
+[bats-docs]: https://github.com/ztombol/bats-docs
+[bats-assert]: https://github.com/ztombol/bats-assert
diff --git a/load.bash b/load.bash
new file mode 100644
index 0000000..325c5b1
--- /dev/null
+++ b/load.bash
@@ -0,0 +1 @@
+source "$(dirname "${BASH_SOURCE[0]}")/src/output.bash"
diff --git a/src/output.bash b/src/output.bash
new file mode 100644
index 0000000..e407a9a
--- /dev/null
+++ b/src/output.bash
@@ -0,0 +1,279 @@
+#
+# bats-core - Supporting library for Bats test helpers
+#
+# Written in 2016 by Zoltan Tombol <zoltan dot tombol at gmail dot com>
+#
+# 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
+# <http://creativecommons.org/publicdomain/zero/1.0/>. 
+#
+
+#
+# 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
+      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 --"
+  cat -
+  echo '--'
+  echo
+}
diff --git a/test/50-output-10-batslib_err.bats b/test/50-output-10-batslib_err.bats
new file mode 100755
index 0000000..8c27fd1
--- /dev/null
+++ b/test/50-output-10-batslib_err.bats
@@ -0,0 +1,16 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test 'batslib_err() <message...>: displays <message...>' {
+  run batslib_err 'm1' 'm2'
+  [ "$status" -eq 0 ]
+  [ "$output" == 'm1 m2' ]
+}
+
+@test 'batslib_err(): reads <message...> from STDIN' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               echo 'm1' 'm2' | batslib_err"
+  [ "$status" -eq 0 ]
+  [ "$output" == 'm1 m2' ]
+}
diff --git a/test/50-output-11-batslib_count_lines.bats b/test/50-output-11-batslib_count_lines.bats
new file mode 100755
index 0000000..ea172c3
--- /dev/null
+++ b/test/50-output-11-batslib_count_lines.bats
@@ -0,0 +1,21 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test 'batslib_count_lines() <string>: displays the number of lines in <string>' {
+  run batslib_count_lines $'a\nb\nc\n'
+  [ "$status" -eq 0 ]
+  [ "$output" == '3' ]
+}
+
+@test 'batslib_count_lines() <string>: counts the last line when it is not terminated by a newline' {
+  run batslib_count_lines $'a\nb\nc'
+  [ "$status" -eq 0 ]
+  [ "$output" == '3' ]
+}
+
+@test 'batslib_count_lines() <string>: counts empty lines' {
+  run batslib_count_lines $'\n\n\n'
+  [ "$status" -eq 0 ]
+  [ "$output" == '3' ]
+}
diff --git a/test/50-output-12-batslib_is_single_line.bats b/test/50-output-12-batslib_is_single_line.bats
new file mode 100755
index 0000000..484b64d
--- /dev/null
+++ b/test/50-output-12-batslib_is_single_line.bats
@@ -0,0 +1,13 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test 'batslib_is_single_line() <string...>: returns 0 if all <string...> are single-line' {
+  run batslib_is_single_line 'a' $'b\n' 'c'
+  [ "$status" -eq 0 ]
+}
+
+@test 'batslib_is_single_line() <string...>: returns 1 if at least one of <string...> is longer than one line' {
+  run batslib_is_single_line 'a' $'b\nb' 'c'
+  [ "$status" -eq 1 ]
+}
diff --git a/test/50-output-13-batslib_get_max_single_line_key_width.bats b/test/50-output-13-batslib_get_max_single_line_key_width.bats
new file mode 100755
index 0000000..e6af161
--- /dev/null
+++ b/test/50-output-13-batslib_get_max_single_line_key_width.bats
@@ -0,0 +1,21 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test 'batslib_get_max_single_line_key_width() <pair...>: displays the length of the longest key' {
+  local -ar pairs=( 'k _1'  'v 1'
+                    'k 2'   'v 2'
+                    'k __3' 'v 3' )
+  run batslib_get_max_single_line_key_width "${pairs[@]}"
+  [ "$status" -eq 0 ]
+  [ "$output" == '5' ]
+}
+
+@test 'batslib_get_max_single_line_key_width() <pair...>: only considers keys with single-line values' {
+  local -ar pairs=( 'k _1'  'v 1'
+                    'k 2'   'v 2'
+                    'k __3' $'v\n3' )
+  run batslib_get_max_single_line_key_width "${pairs[@]}"
+  [ "$status" -eq 0 ]
+  [ "$output" == '4' ]
+}
diff --git a/test/50-output-14-batslib_print_kv_single.bats b/test/50-output-14-batslib_print_kv_single.bats
new file mode 100755
index 0000000..7637897
--- /dev/null
+++ b/test/50-output-14-batslib_print_kv_single.bats
@@ -0,0 +1,27 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test 'batslib_print_kv_single() <width> <pair...>: displays <pair...> in two-column format with <width> wide key column' {
+  local -ar pairs=( 'k _1'  'v 1'
+                    'k 2 '  'v 2'
+                    'k __3' 'v 3' )
+  run batslib_print_kv_single 5 "${pairs[@]}"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" == '3' ]
+  [ "${lines[0]}" == 'k _1  : v 1' ]
+  [ "${lines[1]}" == 'k 2   : v 2' ]
+  [ "${lines[2]}" == 'k __3 : v 3' ]
+}
+
+@test 'batslib_print_kv_single() <width> <pair...>: does not truncate keys when the column is too narrow' {
+  local -ar pairs=( 'k _1'  'v 1'
+                    'k 2'   'v 2'
+                    'k __3' 'v 3' )
+  run batslib_print_kv_single 0 "${pairs[@]}"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" == '3' ]
+  [ "${lines[0]}" == 'k _1 : v 1' ]
+  [ "${lines[1]}" == 'k 2 : v 2' ]
+  [ "${lines[2]}" == 'k __3 : v 3' ]
+}
diff --git a/test/50-output-15-batslib_print_kv_multi.bats b/test/50-output-15-batslib_print_kv_multi.bats
new file mode 100755
index 0000000..6ad4b3d
--- /dev/null
+++ b/test/50-output-15-batslib_print_kv_multi.bats
@@ -0,0 +1,19 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test 'batslib_print_kv_multi() <pair...>: displays <pair...> in multi-line format' {
+  local -ar pairs=( 'k _1'  'v 1'
+                    'k 2'   $'v 2-1\nv 2-2'
+                    'k __3' 'v 3' )
+  run batslib_print_kv_multi "${pairs[@]}"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" == '7' ]
+  [ "${lines[0]}" == 'k _1 (1 lines):' ]
+  [ "${lines[1]}" == 'v 1' ]
+  [ "${lines[2]}" == 'k 2 (2 lines):' ]
+  [ "${lines[3]}" == 'v 2-1' ]
+  [ "${lines[4]}" == 'v 2-2' ]
+  [ "${lines[5]}" == 'k __3 (1 lines):' ]
+  [ "${lines[6]}" == 'v 3' ]
+}
diff --git a/test/50-output-16-batslib_print_kv_single_or_multi.bats b/test/50-output-16-batslib_print_kv_single_or_multi.bats
new file mode 100755
index 0000000..c20d101
--- /dev/null
+++ b/test/50-output-16-batslib_print_kv_single_or_multi.bats
@@ -0,0 +1,31 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test 'batslib_print_kv_single_or_multi() <width> <pair...>: displays <pair...> in two-column format if all values are one line long' {
+  local -ar pairs=( 'k _1'  'v 1'
+                    'k 2 '  'v 2'
+                    'k __3' 'v 3' )
+  run batslib_print_kv_single_or_multi 5 "${pairs[@]}"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" == '3' ]
+  [ "${lines[0]}" == 'k _1  : v 1' ]
+  [ "${lines[1]}" == 'k 2   : v 2' ]
+  [ "${lines[2]}" == 'k __3 : v 3' ]
+}
+
+@test 'batslib_print_kv_single_or_multi() <width> <pair...>: displays <pair...> in multi-line format if at least one value is longer than one line' {
+  local -ar pairs=( 'k _1'  'v 1'
+                    'k 2'   $'v 2-1\nv 2-2'
+                    'k __3' 'v 3' )
+  run batslib_print_kv_single_or_multi 5 "${pairs[@]}"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" == '7' ]
+  [ "${lines[0]}" == 'k _1 (1 lines):' ]
+  [ "${lines[1]}" == '  v 1' ]
+  [ "${lines[2]}" == 'k 2 (2 lines):' ]
+  [ "${lines[3]}" == '  v 2-1' ]
+  [ "${lines[4]}" == '  v 2-2' ]
+  [ "${lines[5]}" == 'k __3 (1 lines):' ]
+  [ "${lines[6]}" == '  v 3' ]
+}
diff --git a/test/50-output-17-batslib_prefix.bats b/test/50-output-17-batslib_prefix.bats
new file mode 100755
index 0000000..817fd33
--- /dev/null
+++ b/test/50-output-17-batslib_prefix.bats
@@ -0,0 +1,43 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test 'batslib_prefix() <prefix>: prefixes each line of the input with <prefix>' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               printf 'a\nb\nc\n' | batslib_prefix '_'"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 3 ]
+  [ "${lines[0]}" == '_a' ]
+  [ "${lines[1]}" == '_b' ]
+  [ "${lines[2]}" == '_c' ]
+}
+
+@test 'batslib_prefix() <prefix>: prefixes the last line when it is not terminated by a newline' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               printf 'a\nb\nc' | batslib_prefix '_'"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 3 ]
+  [ "${lines[0]}" == '_a' ]
+  [ "${lines[1]}" == '_b' ]
+  [ "${lines[2]}" == '_c' ]
+}
+
+@test 'batslib_prefix() <prefix>: prefixes empty lines' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               printf '\n\n\n' | batslib_prefix '_'"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 3 ]
+  [ "${lines[0]}" == '_' ]
+  [ "${lines[1]}" == '_' ]
+  [ "${lines[2]}" == '_' ]
+}
+
+@test 'batslib_prefix(): <prefix> default to two spaces' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               printf 'a\nb\nc\n' | batslib_prefix"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 3 ]
+  [ "${lines[0]}" == '  a' ]
+  [ "${lines[1]}" == '  b' ]
+  [ "${lines[2]}" == '  c' ]
+}
diff --git a/test/50-output-18-batslib_mark.bats b/test/50-output-18-batslib_mark.bats
new file mode 100755
index 0000000..c5d0975
--- /dev/null
+++ b/test/50-output-18-batslib_mark.bats
@@ -0,0 +1,72 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test 'batslib_mark() <mark> <index>: marks the <index>-th line of the input with <mark>' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               printf ' a\n b\n c\n' | batslib_mark '>' 0"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 3 ]
+  [ "${lines[0]}" == '>a' ]
+  [ "${lines[1]}" == ' b' ]
+  [ "${lines[2]}" == ' c' ]
+}
+
+@test 'batslib_mark() <mark> <index...>: marks multiple lines when <index...> is in ascending order' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               printf ' a\n b\n c\n' | batslib_mark '>' 1 2"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 3 ]
+  [ "${lines[0]}" == ' a' ]
+  [ "${lines[1]}" == '>b' ]
+  [ "${lines[2]}" == '>c' ]
+}
+
+@test 'batslib_mark() <mark> <index...>: marks multiple lines when <index...> is in random order' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               printf ' a\n b\n c\n d\n' | batslib_mark '>' 2 1 3"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 4 ]
+  [ "${lines[0]}" == ' a' ]
+  [ "${lines[1]}" == '>b' ]
+  [ "${lines[2]}" == '>c' ]
+  [ "${lines[3]}" == '>d' ]
+}
+
+@test 'batslib_mark() <mark> <index...>: ignores duplicate indices' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               printf ' a\n b\n c\n' | batslib_mark '>' 1 2 1"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 3 ]
+  [ "${lines[0]}" == ' a' ]
+  [ "${lines[1]}" == '>b' ]
+  [ "${lines[2]}" == '>c' ]
+}
+
+@test 'batslib_mark() <mark> <index...>: outputs the input untouched if <mark> is the empty string' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               printf ' a\n b\n c\n' | batslib_mark '' 1"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 3 ]
+  [ "${lines[0]}" == ' a' ]
+  [ "${lines[1]}" == ' b' ]
+  [ "${lines[2]}" == ' c' ]
+}
+
+@test 'batslib_mark() <mark> <index>: marks the last line when it is not terminated by a newline' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               printf ' a\n b\n c' | batslib_mark '>' 2"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 3 ]
+  [ "${lines[0]}" == ' a' ]
+  [ "${lines[1]}" == ' b' ]
+  [ "${lines[2]}" == '>c' ]
+}
+
+@test 'batslib_mark() <mark> <index>: does not truncate <mark> if it is longer than the marked line' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               printf '\n' | batslib_mark '>' 0"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 1 ]
+  [ "${lines[0]}" == '>' ]
+}
diff --git a/test/50-output-19-batslib_decorate.bats b/test/50-output-19-batslib_decorate.bats
new file mode 100755
index 0000000..02d55ad
--- /dev/null
+++ b/test/50-output-19-batslib_decorate.bats
@@ -0,0 +1,13 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test 'batslib_decorate() <title>: encloses the input in a footer line and a header line containing <title>' {
+  run bash -c "source '${TEST_MAIN_DIR}/load.bash'
+               echo 'body' | batslib_decorate 'title'"
+  [ "$status" -eq 0 ]
+  [ "${#lines[@]}" -eq 3 ]
+  [ "${lines[0]}" == '-- title --' ]
+  [ "${lines[1]}" == 'body' ]
+  [ "${lines[2]}" == '--' ]
+}
diff --git a/test/test_helper.bash b/test/test_helper.bash
new file mode 100644
index 0000000..ca16775
--- /dev/null
+++ b/test/test_helper.bash
@@ -0,0 +1,6 @@
+setup() {
+  export TEST_MAIN_DIR="${BATS_TEST_DIRNAME}/.."
+
+  # Load library.
+  load '../load'
+}