diff options
Diffstat (limited to 'tests')
-rw-r--r-- | tests/aux/json.sh | 402 | ||||
-rw-r--r-- | tests/local.mk | 3 |
2 files changed, 404 insertions, 1 deletions
diff --git a/tests/aux/json.sh b/tests/aux/json.sh new file mode 100644 index 0000000..8f2cf9d --- /dev/null +++ b/tests/aux/json.sh @@ -0,0 +1,402 @@ +# `json.sh`, a pure-shell JSON parser. +# +# Copied from <lib/json.sh> in repository <https://github.com/rcrowley/json.sh>. +# +# Copyright 2011 Richard Crowley. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided +# with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY RICHARD CROWLEY AS IS'' AND ANY EXPRESS +# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL RICHARD CROWLEY OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +# THE POSSIBILITY OF SUCH DAMAGE. +# +# The views and conclusions contained in the software and documentation +# are those of the authors and should not be interpreted as representing +# official policies, either expressed or implied, of Richard Crowley. + +set -e + +# Most users will be happy with the default '/' separator that makes trees +# of keys look like filesystem paths but that breaks down if keys can +# contain slashes. In that case, set `JSON_SEPARATOR` to desired character. +[ -z "$JSON_SEPARATOR" ] && _J_S="/" || _J_S="$JSON_SEPARATOR" + +# File descriptor 3 is commandeered for debug output, which may end up being +# forwarded to standard error. +[ -z "$JSON_DEBUG" ] && exec 3>/dev/null || exec 3>&2 + +# File descriptor 4 is commandeered for use as a sink for literal and +# variable output of (inverted) sections that are not destined for standard +# output because their condition is not met. +exec 4>/dev/null + +# Consume standard input one character at a time to parse JSON. +json() { + + # Initialize the file descriptor to be used to emit characters. At + # times this value will be 4 to send output to `/dev/null`. + _J_FD=1 + + # Initialize storage for the "pathname", the concatenation of all + # the keys in the tree at any point in time, the current state of + # the machine, and the state to which the machine returns after + # completing a key or value. + _J_PATHNAME="$_J_S" _J_STATE="whitespace" _J_STATE_DEFAULT="whitespace" + + # IFS must only contain '\n' so as to be able to read space and tab + # characters from standard input one-at-a-time. The easiest way to + # convince it to actually contain the correct byte, and only the + # correct byte, is to use a single-quoted literal newline. + IFS=' +' + + # Consuming standard input one character at a time is quite a feat + # within the confines of POSIX shell. Bash's `read` builtin has + # `-n` for limiting the number of characters consumed. Here it is + # faked using `sed`(1) to place each character on its own line. + # The subtlety is that real newline characters are chomped so they + # must be indirectly detected by checking for zero-length + # characters, which is done as the character is emitted. + sed " + s/./&$(printf "\036")/g + s/\\\\/\\\\\\\\/g + " | tr "\036" "\n" | _json + + # TODO Replace the original value of IFS. Be careful if it's unset. + +} + +# Consume the one-character-per-line stream from `sed` via a state machine. +# This function will be called recursively in subshell environments to +# isolate values from their containing scope. +# +# The `read` builtin consumes one line at a time but by now each line +# contains only a single character. +_json() { + while read _J_C + do + _json_char + _J_PREV_C="$_J_C" + done +} + +# Consume a single character as stored in `_J_C`. This function is broken +# out from `_json` so it may be called to reconsume a character as is +# necessary following the end of any number since numbers do not have a +# well-known ending in the grammar. +# +# The state machine implemented here follows very naturally from the +# diagrams of the JSON grammar on <http://json.org>. +_json_char() { + echo " _J_C: $_J_C (${#_J_C}), _J_STATE: $_J_STATE" >&3 + case "$_J_STATE" in + + # The machine starts in the "whitespace" state and learns + # from leading characters what state to enter next. JSON's + # grammar doesn't contain any tokens that are ambiguous in + # their first character so the parser's job is relatively + # easier. + # + # Further whitespace characters are consumed and ignored. + # + # Arrays are unique in that their parsing rules are a strict + # superset of the rules in open whitespace. When an opening + # bracket is encountered, the remainder of the array is + # parsed in a subshell which goes around again when a comma + # is encountered and exits back to the containing scope when + # the closing bracket is encountered. + # + # Objects are not parsed as a superset of open whitespace but + # they are parsed in a subshell to protect the containing scope. + "array-0"|"array-even"|"array-odd"|"whitespace") + case "$_J_STATE" in + "array-0") + case "$_J_C" in + "]") exit;; + esac;; + "array-even") + case "$_J_C" in + ",") + _J_DIRNAME="${_J_PATHNAME%"$_J_S"*}" + [ "$_J_DIRNAME" = "$_J_S" ] && _J_DIRNAME="" + _J_BASENAME="${_J_PATHNAME##*"$_J_S"}" + _J_BASENAME="$(($_J_BASENAME + 1))" + _J_PATHNAME="$_J_DIRNAME$_J_S$_J_BASENAME" + _J_STATE="array-odd" + continue;; + "]") exit;; + esac;; + esac + case "$_J_C" in + "\"") _J_STATE="string" _J_V="";; + "-") _J_STATE="number-negative" _J_V="$_J_C";; + 0) _J_STATE="number-leading-zero" _J_V="$_J_C";; + [1-9]) _J_STATE="number-leading-nonzero" _J_V="$_J_C";; + "[") + ( + [ "$_J_PATHNAME" = "/" ] && _J_PATHNAME="" + _J_PATHNAME="$_J_PATHNAME/0" + _J_STATE="array-0" _J_STATE_DEFAULT="array-even" + _json + ) + _J_STATE="$_J_STATE_DEFAULT" _J_V="";; + "f"|"t") _J_STATE="boolean" _J_V="$_J_C";; + "n") _J_STATE="null" _J_V="$_J_C";; + "{") + ( + _J_STATE="object-0" _J_STATE_DEFAULT="object-even" + _json + ) + _J_STATE="$_J_STATE_DEFAULT" _J_V="";; + " "|""|" ") ;; + *) _json_die "syntax: $_J_PATHNAME";; + esac;; + + # Boolean values are multicharacter literals but they're unique + # from their first character. This means the eventual value is + # already known when the "boolean" state is entered so we can + # raise syntax errors as soon as the input goes south. + "boolean") + case "$_J_V$_J_C" in + "f"|"fa"|"fal"|"fals"|"t"|"tr"|"tru") _J_V="$_J_V$_J_C";; + "false"|"true") + _J_STATE="$_J_STATE_DEFAULT" + echo "$_J_PATHNAME boolean $_J_V$_J_C" >&$_J_FD;; + *) _json_die "syntax: $_J_PATHNAME boolean $_J_V$_J_C";; + esac;; + + # Object values are relatively more complex than array values. + # They begin in the "object-0" state, which is almost but not + # quite a subset of the "whitespace" state for strings. When + # a string is encountered it is parsed as usual but the parser + # is set to return to the "object-value" state afterward. + # + # As in the "whitespace" state, extra whitespace characters + # are consumed and ignored. + # + # The parser will return to this "object" state later to + # either consume a comma and go around again or exit the + # subshell in which this object has been parsed. + "object-0") + case "$_J_C" in + "\"") + _J_FD=4 + _J_STATE="string" + _J_STATE_DEFAULT="object-value" + _J_V="";; + "}") exit;; + " "|""|" ") ;; + *) _json_die "syntax: $_J_PATHNAME";; + esac;; + + # "object-even" is like "object-0" but additionally commas are + # consumed to enforce the another key/value pair is coming. + "object-even") + case "$_J_C" in + "\"") + _J_FD=4 + _J_STATE="string" + _J_STATE_DEFAULT="object-value" + _J_V="";; + ",") _J_STATE="object-odd";; + "}") exit;; + " "|""|" ") ;; + *) _json_die "syntax: $_J_PATHNAME";; + esac;; + + # Object values have to return from whence they came. They use + # the "object-exit" state to signal the last character consumed + # to the containing scope. + "object-exit") #exit;; + case "$_J_C" in + ",") exit 101;; + "}") exit 102;; + *) exit 0;; + esac;; + + # "object-even" is like "object-0" but cannot consume a closing + # brace because it has just consumed a comma. + "object-odd") + case "$_J_C" in + "\"") + _J_FD=4 + _J_STATE="string" + _J_STATE_DEFAULT="object-value" + _J_V="";; + " "|""|" ") ;; + *) _json_die "syntax: $_J_PATHNAME";; + esac;; + + # After a string key has been consumed, the state machine + # progresses here where a colon and a value are parsed. The + # value is parsed in a subshell so the pathname can have the + # key appended to it before the parser continues. + "object-value") + case "$_J_C" in + ":") + _J_FD=1 + ( + [ "$_J_PATHNAME" = "/" ] && _J_PATHNAME="" + _J_PATHNAME="$_J_PATHNAME/$_J_V" + _J_STATE="whitespace" + _J_STATE_DEFAULT="object-exit" + _json + ) || case "$?" in + 101) _J_STATE="object-even" _J_C="," _json_char;; + 102) _J_STATE="object-even" _J_C="}" _json_char;; + esac + _J_STATE="object-even";; + " "|""|" ") ;; + *) _json_die "syntax: $_J_PATHNAME";; + esac;; + + # Null values work exactly like boolean values. See above. + "null") + case "$_J_V$_J_C" in + "n"|"nu"|"nul") _J_V="$_J_V$_J_C";; + "null") + _J_STATE="$_J_STATE_DEFAULT" + echo "$_J_PATHNAME null null" >&$_J_FD;; + *) _json_die "syntax: $_J_PATHNAME null $_J_V$_J_C";; + esac;; + + # Numbers that encounter a '.' become floating point and may + # continue consuming digits forever or may become + # scientific-notation. Any other character sends the parser + # back to its default state. + "number-float") + case "$_J_C" in + [0-9]) _J_V="$_J_V$_J_C";; + "E"|"e") _J_STATE="number-sci" _J_V="$_J_V$_J_C";; + *) + _J_STATE="$_J_STATE_DEFAULT" + echo "$_J_PATHNAME number $_J_V" >&$_J_FD + _json_char;; + esac;; + + # This is an entrypoint into parsing a number, used when + # the first digit consumed is non-zero. From here, a number + # may continue on a positive integer, become a floating-point + # number by consuming a '.', or become scientific-notation by + # consuming an 'E' or 'e'. Any other character sends the + # parser back to its default state. + "number-leading-nonzero") + case "$_J_C" in + ".") _J_STATE="number-float" _J_V="$_J_V$_J_C";; + [0-9]) _J_V="$_J_V$_J_C";; + "E"|"e") _J_STATE="number-sci" _J_V="$_J_V$_J_C";; + *) + _J_STATE="$_J_STATE_DEFAULT" + echo "$_J_PATHNAME number $_J_V" >&$_J_FD + _json_char;; + esac;; + + # This is an entrypoint into parsing a number, used when + # the first digit consumed is zero. From here, a number + # may remain zero, become a floating-point number by + # consuming a '.', or become scientific-notation by consuming + # an 'E' or 'e'. Any other character sends the parser back + # to its default state. + "number-leading-zero") + case "$_J_C" in + ".") _J_STATE="number-float" _J_V="$_J_V$_J_C";; + [0-9]) _json_die "syntax: $_J_PATHNAME number $_J_V$_J_C";; + "E"|"e") _J_STATE="number-sci" _J_V="$_J_V$_J_C";; + *) + _J_STATE="$_J_STATE_DEFAULT" + echo "$_J_PATHNAME number $_J_V" >&$_J_FD + _json_char;; + esac;; + + # This is an entrypoint into parsing a number, used when + # the first character consumed is a '-'. From here, a number + # may progress to the "number-leading-nonzero" or + # "number-leading-zero" states. Any other character sends + # the parser back to its default state. + "number-negative") + case "$_J_C" in + 0) _J_STATE="number-leading-zero" _J_V="$_J_V$_J_C";; + [1-9]) + _J_STATE="number-leading-nonzero" + _J_V="$_J_V$_J_C";; + *) + _J_STATE="$_J_STATE_DEFAULT" + echo "$_J_PATHNAME number $_J_V" >&$_J_FD + _json_char;; + esac;; + + # Numbers that encounter an 'E' or 'e' become + # scientific-notation and consume digits, optionally prefixed + # by a '+' or '-', forever. The actual consumption is + # delegated to the "number-sci-neg" and "number-sci-pos" + # states. Any other character immediately following the 'E' + # or 'e' is a syntax error. + "number-sci") + case "$_J_C" in + "+") _J_STATE="number-sci-pos" _J_V="$_J_V$_J_C";; + "-") _J_STATE="number-sci-neg" _J_V="$_J_V$_J_C";; + [0-9]) _J_STATE="number-sci-pos" _J_V="$_J_V$_J_C";; + *) _json_die "syntax: $_J_PATHNAME number $_J_V$_J_C";; + esac;; + + # Once in these states, numbers may consume digits forever. + # Any other character sends the parser back to its default + # state. + "number-sci-neg"|"number-sci-pos") + case "$_J_C" in + [0-9]) _J_V="$_J_V$_J_C";; + *) + _J_STATE="$_J_STATE_DEFAULT" + echo "$_J_PATHNAME number $_J_V" >&$_J_FD + _json_char;; + esac;; + + # Strings aren't as easy as they look. JSON supports several + # escape sequences that require the state machine to keep a + # history of its input. Basic backslash/newline/etc. escapes + # are simple because they only require one character of + # history. Unicode codepoint escapes require more. The + # strategy there is to add states to the machine. + # + # TODO It'd be nice to decode all escape sequences, including + # Unicode codepoints but that would definitely ruin the + # line-oriented thing we've got goin' on. + "string") + case "$_J_PREV_C$_J_C" in + "\\\""|"\\/"|"\\\\") _J_V="$_J_V$_J_C";; + "\\b"|"\\f"|"\\n"|"\\r") _J_V="$_J_V\\\\$_J_C";; + "\\u") _J_V="$_J_V\\\\$_J_C";; + *"\"") + _J_STATE="$_J_STATE_DEFAULT" + echo "$_J_PATHNAME string $_J_V" >&$_J_FD;; + *"\\") ;; + *) _J_V="$_J_V$_J_C";; + esac;; + + esac +} + +# Print an error message and GTFO. The message is the concatenation +# of all the arguments to this function. +_json_die() { + echo "json.sh: $*" >&2 + exit 1 +} diff --git a/tests/local.mk b/tests/local.mk index 6d7980c..288b9ad 100644 --- a/tests/local.mk +++ b/tests/local.mk @@ -9,4 +9,5 @@ SH_LOG_DRIVER = \ EXTRA_DIST += \ $(TESTS) \ build-aux/tap-driver.sh \ - tests/aux/tap-functions.sh + tests/aux/tap-functions.sh \ + tests/aux/json.sh |