From febd3467c8a6f3285ba98e132c7f31975403248a Mon Sep 17 00:00:00 2001 From: Tyler Akins Date: Fri, 7 Apr 2023 19:35:25 -0500 Subject: [PATCH] Attempting to address shortcomings and whitespace issues --- README.md | 18 +- mo | 1944 ++++++++++------- run-tests | 139 +- tests/ampersand | 9 + tests/array | 21 + tests/array.env | 1 - tests/array.expected | 3 - tests/array.template | 3 - tests/assoc-array | 24 + tests/assoc-array.env | 4 - tests/assoc-array.expected | 3 - tests/assoc-array.template | 3 - tests/comment | 8 + tests/comment-newline | 19 + tests/comment-newline.env | 0 tests/comment-newline.expected | 1 - tests/comment-newline.template | 4 - tests/comment-with-spaces | 8 + tests/comment.env | 0 tests/comment.expected | 1 - tests/comment.template | 1 - tests/concatenated-variables | 10 + tests/delimiters | 9 + tests/double-hyphen | 9 + tests/double-hyphen.expected | 1 - tests/double-hyphen.sh | 5 - tests/double-quote | 8 + tests/fail-not-set | 24 + tests/fail-not-set-file.expected | 1 - tests/fail-not-set-file.sh | 9 - tests/fail-not-set-file.template | 3 - tests/fail-not-set.expected | 3 - tests/fail-not-set.sh | 13 - tests/fail-on-function | 18 + tests/fail-on-function.expected | 1 - tests/fail-on-function.sh | 18 - tests/false-is-empty-arg | 22 + tests/false-is-empty-arg.expected | 1 - tests/false-is-empty-arg.sh | 4 - tests/false-is-empty-arg.template | 4 - tests/false-is-empty-env | 22 + tests/false-is-empty-env.env | 2 - tests/false-is-empty-env.expected | 1 - tests/false-is-empty-env.template | 4 - tests/false-list | 20 + tests/false-list.env | 1 - tests/false-list.expected | 1 - tests/false-list.template | 4 - .../{ => fixtures}/indented-partials.partial | 0 .../{ => fixtures}/multi-line-partial.partial | 0 tests/{ => fixtures}/partial.partial | 0 tests/{ => fixtures}/source-multiple-1.vars | 0 tests/{ => fixtures}/source-multiple-2.vars | 0 tests/{ => fixtures}/source.vars | 0 tests/function | 24 + tests/function-args | 35 + tests/function-args-read | 25 + tests/function-args-read.env | 3 - tests/function-args-read.expected | 4 - tests/function-args-read.template | 4 - tests/function-args.env | 13 - tests/function-args.expected | 4 - tests/function-args.template | 4 - tests/function.env | 5 - tests/function.expected | 1 - tests/function.template | 4 - tests/globals-in-loop | 28 + tests/globals-in-loop.env | 2 - tests/globals-in-loop.expected | 6 - tests/globals-in-loop.template | 6 - tests/{help.expected => help} | 14 +- tests/help.sh | 4 - tests/indented-partials | 56 + tests/indented-partials.env | 1 - tests/indented-partials.expected | 23 - tests/indented-partials.template | 18 - tests/invalid-option | 14 + tests/invalid-option.expected | 1 - tests/invalid-option.sh | 5 - tests/inverted | 22 + tests/inverted.env | 1 - tests/inverted.expected | 2 - tests/inverted.template | 6 - tests/miss | 24 + tests/miss.env | 2 - tests/miss.expected | 4 - tests/miss.template | 4 - tests/multi-line-partial | 31 + tests/multi-line-partial.env | 1 - tests/multi-line-partial.expected | 9 - tests/multi-line-partial.template | 7 - tests/mush | 33 + tests/mush.env | 6 - tests/mush.expected | 6 - tests/mush.template | 7 - tests/no-content | 8 + tests/partial | 22 + tests/partial-missing | 18 + tests/partial-missing.expected | 1 - tests/partial-missing.sh | 8 - tests/partial-missing.template | 1 - tests/partial.env | 1 - tests/partial.expected | 3 - tests/partial.template | 4 - tests/single-quote | 8 + tests/single-variable-replacement | 9 + tests/source | 25 + tests/source-bad-file | 14 + tests/source-bad-file.expected | 1 - tests/source-bad-file.sh | 8 - tests/source-multiple | 21 + tests/source-multiple.expected | 3 - tests/source-multiple.sh | 8 - tests/source-no-file | 10 + tests/source-no-file.expected | 1 - tests/source-no-file.sh | 8 - tests/source.expected | 5 - tests/source.sh | 10 - tests/triple-brace | 9 + tests/typical | 26 + tests/typical.env | 4 - tests/typical.expected | 3 - tests/typical.template | 5 - 123 files changed, 2008 insertions(+), 1137 deletions(-) create mode 100755 tests/ampersand create mode 100755 tests/array delete mode 100644 tests/array.env delete mode 100644 tests/array.expected delete mode 100644 tests/array.template create mode 100755 tests/assoc-array delete mode 100644 tests/assoc-array.env delete mode 100644 tests/assoc-array.expected delete mode 100644 tests/assoc-array.template create mode 100755 tests/comment create mode 100755 tests/comment-newline delete mode 100644 tests/comment-newline.env delete mode 100644 tests/comment-newline.expected delete mode 100644 tests/comment-newline.template create mode 100755 tests/comment-with-spaces delete mode 100644 tests/comment.env delete mode 100644 tests/comment.expected delete mode 100644 tests/comment.template create mode 100755 tests/concatenated-variables create mode 100755 tests/delimiters create mode 100755 tests/double-hyphen delete mode 100644 tests/double-hyphen.expected delete mode 100755 tests/double-hyphen.sh create mode 100755 tests/double-quote create mode 100755 tests/fail-not-set delete mode 100644 tests/fail-not-set-file.expected delete mode 100755 tests/fail-not-set-file.sh delete mode 100644 tests/fail-not-set-file.template delete mode 100644 tests/fail-not-set.expected delete mode 100755 tests/fail-not-set.sh create mode 100755 tests/fail-on-function delete mode 100644 tests/fail-on-function.expected delete mode 100755 tests/fail-on-function.sh create mode 100755 tests/false-is-empty-arg delete mode 100644 tests/false-is-empty-arg.expected delete mode 100755 tests/false-is-empty-arg.sh delete mode 100644 tests/false-is-empty-arg.template create mode 100755 tests/false-is-empty-env delete mode 100644 tests/false-is-empty-env.env delete mode 100644 tests/false-is-empty-env.expected delete mode 100644 tests/false-is-empty-env.template create mode 100755 tests/false-list delete mode 100644 tests/false-list.env delete mode 100644 tests/false-list.expected delete mode 100644 tests/false-list.template rename tests/{ => fixtures}/indented-partials.partial (100%) rename tests/{ => fixtures}/multi-line-partial.partial (100%) rename tests/{ => fixtures}/partial.partial (100%) rename tests/{ => fixtures}/source-multiple-1.vars (100%) rename tests/{ => fixtures}/source-multiple-2.vars (100%) rename tests/{ => fixtures}/source.vars (100%) create mode 100755 tests/function create mode 100755 tests/function-args create mode 100755 tests/function-args-read delete mode 100644 tests/function-args-read.env delete mode 100644 tests/function-args-read.expected delete mode 100644 tests/function-args-read.template delete mode 100644 tests/function-args.env delete mode 100644 tests/function-args.expected delete mode 100644 tests/function-args.template delete mode 100644 tests/function.env delete mode 100644 tests/function.expected delete mode 100644 tests/function.template create mode 100755 tests/globals-in-loop delete mode 100644 tests/globals-in-loop.env delete mode 100644 tests/globals-in-loop.expected delete mode 100644 tests/globals-in-loop.template rename tests/{help.expected => help} (85%) mode change 100644 => 100755 delete mode 100755 tests/help.sh create mode 100755 tests/indented-partials delete mode 100644 tests/indented-partials.env delete mode 100644 tests/indented-partials.expected delete mode 100644 tests/indented-partials.template create mode 100755 tests/invalid-option delete mode 100644 tests/invalid-option.expected delete mode 100755 tests/invalid-option.sh create mode 100755 tests/inverted delete mode 100644 tests/inverted.env delete mode 100644 tests/inverted.expected delete mode 100644 tests/inverted.template create mode 100755 tests/miss delete mode 100644 tests/miss.env delete mode 100644 tests/miss.expected delete mode 100644 tests/miss.template create mode 100755 tests/multi-line-partial delete mode 100644 tests/multi-line-partial.env delete mode 100644 tests/multi-line-partial.expected delete mode 100644 tests/multi-line-partial.template create mode 100755 tests/mush delete mode 100644 tests/mush.env delete mode 100644 tests/mush.expected delete mode 100644 tests/mush.template create mode 100755 tests/no-content create mode 100755 tests/partial create mode 100755 tests/partial-missing delete mode 100644 tests/partial-missing.expected delete mode 100755 tests/partial-missing.sh delete mode 100644 tests/partial-missing.template delete mode 100644 tests/partial.env delete mode 100644 tests/partial.expected delete mode 100644 tests/partial.template create mode 100755 tests/single-quote create mode 100755 tests/single-variable-replacement create mode 100755 tests/source create mode 100755 tests/source-bad-file delete mode 100644 tests/source-bad-file.expected delete mode 100755 tests/source-bad-file.sh create mode 100755 tests/source-multiple delete mode 100644 tests/source-multiple.expected delete mode 100755 tests/source-multiple.sh create mode 100755 tests/source-no-file delete mode 100644 tests/source-no-file.expected delete mode 100755 tests/source-no-file.sh delete mode 100644 tests/source.expected delete mode 100755 tests/source.sh create mode 100755 tests/triple-brace create mode 100755 tests/typical delete mode 100644 tests/typical.env delete mode 100644 tests/typical.expected delete mode 100644 tests/typical.template diff --git a/README.md b/README.md index efb195e..15abb83 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,21 @@ myfunc() { ``` +Environment Variables and Functions +----------------------------------- + +There are several functions and variables used to process templates. `mo` reserves variables that start with `MO_` for variables exposing data or configuration, functions starting with `mo::`, and local variables starting with `mo[A-Z]`. You are welcome to use internal functions, though only ones that are marked as "Public" should not change their interface. Scripts may also read any of the variables. + +* `MO_ALLOW_FUNCTION_ARGUMENTS` - When set to a non-empty value, this allows functions referenced in templates to receive additional options and arguments. This puts the content from the template directly into an eval statement. Use with extreme care. +* `MO_DEBUG` - When set to a non-empty value, additional debug information is written to stderr. +* `MO_FUNCTION_ARGS` - Arguments passed to the function. +* `MO_FAIL_ON_FUNCTION` - If a function returns a non-zero status code, abort with an error. +* `MO_FAIL_ON_UNSET` - When set to a non-empty value, expansion of an unset env variable will be aborted with an error. +* `MO_FALSE_IS_EMPTY` - When set to a non-empty value, the string "false" will be treated as an empty value for the purposes of conditionals. +* `MO_ORIGINAL_COMMAND` - Used to find the `mo` program in order to generate a help message. +* `MO_VERSION` - Version of `mo`. + + Concessions ----------- @@ -200,9 +215,8 @@ Pull requests to solve the following issues would be helpful. ### Mustache Syntax * Dotted names are supported but only for associative arrays (Bash 4). See [`demo/associative-arrays`](demo/associative-arrays) for an example. -* There's no "top level" object, so `echo '{.}' | ./mo` does not do anything useful. In other languages you can say the data for the template is a string and in `mo` the data is always the environment. Luckily this type of usage is rare and `{.}` works great when iterating over an array. +* There's no "top level" object, so `echo '{{.}}' | ./mo` does not do anything useful. In other languages you can say the data for the template is a string and in `mo` the data is always the environment. Luckily this type of usage is rare and `{{.}}` works great when iterating over an array. * HTML encoding is not built into `mo`. `{{{var}}}`, `{{&var}}` and `{{var}}` all do the same thing. `echo '{{TEST}}' | TEST='' mo` will give you "``" instead of "`>b<`". -* You can not change the delimiters. ### General Scripting Issues diff --git a/mo b/mo index e6d729d..1944053 100755 --- a/mo +++ b/mo @@ -26,6 +26,8 @@ #/ -s=FILE, --source=FILE #/ Load FILE into the environment before processing templates. #/ Can be used multiple times. +#/ -d, --debug +#/ Enable debug logging to stderr. # # Mo is under a MIT style licence with an additional non-advertising clause. # See LICENSE.md for the full text. @@ -86,7 +88,9 @@ # options and arguments. This puts the content from the # template directly into an eval statement. Use with extreme # care. -# MO_FUNCTION_ARGS - Arguments passed to the function +# MO_DEBUG - When set to a non-empty value, additional debug information is +# written to stderr. +# MO_FUNCTION_ARGS - Arguments passed to the function. # MO_FAIL_ON_FUNCTION - If a function returns a non-zero status code, abort # with an error. # MO_FAIL_ON_UNSET - When set to a non-empty value, expansion of an unset env @@ -98,24 +102,26 @@ # # Returns nothing. mo() ( - # This function executes in a subshell so IFS is reset. - # Namespace this variable so we don't conflict with desired values. - local moContent f2source files doubleHyphens + local moContent moSource moFiles moDoubleHyphens moResult + # This function executes in a subshell; IFS is reset at the end. IFS=$' \n\t' - files=() - doubleHyphens=false + + # Enable a strict mode. This is also reset at the end. + set -eEu -o pipefail + moFiles=() + moDoubleHyphens=false if [[ $# -gt 0 ]]; then for arg in "$@"; do - if $doubleHyphens; then + if $moDoubleHyphens; then #: After we encounter two hyphens together, all the rest #: of the arguments are files. - files=("${files[@]}" "$arg") + moFiles=(${moFiles[@]+"${moFiles[@]}"} "$arg") else case "$arg" in -h|--h|--he|--hel|--help|-\?) - moUsage "$0" + mo::usage "$0" exit 0 ;; @@ -141,197 +147,95 @@ mo() ( -s=* | --source=*) if [[ "$arg" == --source=* ]]; then - f2source="${arg#--source=}" + moSource="${arg#--source=}" else - f2source="${arg#-s=}" + moSource="${arg#-s=}" fi - if [[ -f "$f2source" ]]; then + if [[ -f "$moSource" ]]; then # shellcheck disable=SC1090 - . "$f2source" + . "$moSource" else - echo "No such file: $f2source" >&2 + echo "No such file: $moSource" >&2 exit 1 fi ;; + -d | --debug) + MO_DEBUG=true + ;; + --) #: Set a flag indicating we've encountered double hyphens - doubleHyphens=true + moDoubleHyphens=true ;; *) #: Every arg that is not a flag or a option should be a file - files=(${files[@]+"${files[@]}"} "$arg") + moFiles=(${moFiles[@]+"${moFiles[@]}"} "$arg") ;; esac fi done fi - moGetContent moContent "${files[@]}" || return 1 - moParse "$moContent" "" true + mo::debug "Debug enabled" + mo::content moContent "${moFiles[@]}" || return 1 + mo::parse moResult "$moContent" "" "" "{{" "}}" "" + echo -n "${moResult[0]}${moResult[1]}" ) -# Internal: Call a function. +# Internal: Show a debug message # -# $1 - Variable for output -# $2 - Function to call -# $3 - Content to pass -# $4 - Additional arguments as a single string -# -# This can be dangerous, especially if you are using tags like -# {{someFunction ; rm -rf / }} +# $1 - The debug message to show # # Returns nothing. -moCallFunction() { - local moArgs moContent moFunctionArgs moFunctionResult - - moArgs=() - moTrimWhitespace moFunctionArgs "$4" - - # shellcheck disable=SC2031 - if [[ -n "${MO_ALLOW_FUNCTION_ARGUMENTS-}" ]]; then - # Intentionally bad behavior - # shellcheck disable=SC2206 - moArgs=($4) +mo::debug() { + if [[ -n "${MO_DEBUG:-}" ]]; then + echo "DEBUG: $1" >&2 fi +} - moContent=$(echo -n "$3" | MO_FUNCTION_ARGS="$moFunctionArgs" eval "$2" "${moArgs[@]}") || { - moFunctionResult=$? - # shellcheck disable=SC2031 - if [[ -n "${MO_FAIL_ON_FUNCTION-}" && "$moFunctionResult" != 0 ]]; then - echo "Function '$2' with args (${moArgs[*]+"${moArgs[@]}"}) failed with status code $moFunctionResult" - exit "$moFunctionResult" + +# Internal: Show an error message and exit +# +# $1 - The error message to show +# +# Returns nothing. Exits the program. +mo::error() { + echo "ERROR: $1" >&2 + exit ${2:-1} +} + + +# Internal: Displays the usage for mo. Pulls this from the file that +# contained the `mo` function. Can only work when the right filename +# comes is the one argument, and that only happens when `mo` is called +# with `$0` set to this file. +# +# $1 - Filename that has the help message +# +# Returns nothing. +mo::usage() { + while read -r line; do + if [[ "${line:0:2}" == "#/" ]]; then + echo "${line:3}" fi - } - - # shellcheck disable=SC2031 - local "$1" && moIndirect "$1" "$moContent" -} - - -# Internal: Scan content until the right end tag is found. Creates an array -# with the following members: -# -# [0] = Content before end tag -# [1] = End tag (complete tag) -# [2] = Content after end tag -# -# Everything using this function uses the "standalone tags" logic. -# -# $1 - Name of variable for the array -# $2 - Content -# $3 - Name of end tag -# $4 - If -z, do standalone tag processing before finishing -# -# Returns nothing. -moFindEndTag() { - local content remaining scanned standaloneBytes tag - - #: Find open tags - scanned="" - moSplit content "$2" '{{' '}}' - - while [[ "${#content[@]}" -gt 1 ]]; do - moTrimWhitespace tag "${content[1]}" - - #: Restore content[1] before we start using it - content[1]='{{'"${content[1]}"'}}' - - case $tag in - '#'* | '^'*) - #: Start another block - scanned="${scanned}${content[0]}${content[1]}" - moTrimWhitespace tag "${tag:1}" - moFindEndTag content "${content[2]}" "$tag" "loop" - scanned="${scanned}${content[0]}${content[1]}" - remaining=${content[2]} - ;; - - '/'*) - #: End a block - could be ours - moTrimWhitespace tag "${tag:1}" - scanned="$scanned${content[0]}" - - if [[ "$tag" == "$3" ]]; then - #: Found our end tag - if [[ -z "${4-}" ]] && moIsStandalone standaloneBytes "$scanned" "${content[2]}" true; then - #: This is also a standalone tag - clean up whitespace - #: and move those whitespace bytes to the "tag" element - # shellcheck disable=SC2206 - standaloneBytes=( $standaloneBytes ) - content[1]="${scanned:${standaloneBytes[0]}}${content[1]}${content[2]:0:${standaloneBytes[1]}}" - scanned="${scanned:0:${standaloneBytes[0]}}" - content[2]="${content[2]:${standaloneBytes[1]}}" - fi - - local "$1" && moIndirectArray "$1" "$scanned" "${content[1]}" "${content[2]}" - return 0 - fi - - scanned="$scanned${content[1]}" - remaining=${content[2]} - ;; - - *) - #: Ignore all other tags - scanned="${scanned}${content[0]}${content[1]}" - remaining=${content[2]} - ;; - esac - - moSplit content "$remaining" '{{' '}}' - done - - #: Did not find our closing tag - scanned="$scanned${content[0]}" - local "$1" && moIndirectArray "$1" "${scanned}" "" "" -} - - -# Internal: Find the first index of a substring. If not found, sets the -# index to -1. -# -# $1 - Destination variable for the index -# $2 - Haystack -# $3 - Needle -# -# Returns nothing. -moFindString() { - local pos string - - string=${2%%"$3"*} - [[ "$string" == "$2" ]] && pos=-1 || pos=${#string} - local "$1" && moIndirect "$1" "$pos" -} - - -# Internal: Generate a dotted name based on current context and target name. -# -# $1 - Target variable to store results -# $2 - Context name -# $3 - Desired variable name -# -# Returns nothing. -moFullTagName() { - if [[ -z "${2-}" ]] || [[ "$2" == *.* ]]; then - local "$1" && moIndirect "$1" "$3" - else - local "$1" && moIndirect "$1" "${2}.${3}" - fi + done < <(cat "$MO_ORIGINAL_COMMAND") + echo "" + echo "MO_VERSION=$MO_VERSION" } # Internal: Fetches the content to parse into a variable. Can be a list of # partials for files or the content from stdin. # -# $1 - Variable name to assign this content back as -# $2-@ - File names (optional) +# $1 - Target variable to store results +# $2-@ - File names (optional), read from stdin otherwise # # Returns nothing. -moGetContent() { +mo::content() { local moContent moFilename moTarget moTarget=$1 @@ -340,87 +244,39 @@ moGetContent() { moContent="" for moFilename in "$@"; do + mo::debug "Using template to load content from file: $moFilename" #: This is so relative paths work from inside template files moContent="$moContent"'{{>'"$moFilename"'}}' done else - moLoadFile moContent || return 1 + mo::debug "Will read content from stdin" + mo::contentFile moContent || return 1 fi - local "$moTarget" && moIndirect "$moTarget" "$moContent" + local "$moTarget" && mo::indirect "$moTarget" "$moContent" } -# Internal: Indent a string, placing the indent at the beginning of every -# line that has any content. +# Internal: Read a file into a variable. # -# $1 - Name of destination variable to get an array of lines -# $2 - The indent string -# $3 - The string to reindent +# $1 - Variable name to receive the file's content +# $2 - Filename to load - if empty, defaults to /dev/stdin # # Returns nothing. -moIndentLines() { - local content fragment len posN posR result trimmed +mo::contentFile() { + local moContent moLen - result="" + # The subshell removes any trailing newlines. We forcibly add + # a dot to the content to preserve all newlines. + # As a future optimization, it would be worth considering removing + # cat and replacing this with a read loop. - #: Remove the period from the end of the string. - len=$((${#3} - 1)) - content=${3:0:$len} + mo::debug "Loading content: ${2:-/dev/stdin}" + moContent=$(cat -- "${2:-/dev/stdin}" && echo '.') || return 1 + moLen=$((${#moContent} - 1)) + moContent=${moContent:0:$moLen} # Remove last dot - if [[ -z "${2-}" ]]; then - local "$1" && moIndirect "$1" "$content" - - return 0 - fi - - moFindString posN "$content" $'\n' - moFindString posR "$content" $'\r' - - while [[ "$posN" -gt -1 ]] || [[ "$posR" -gt -1 ]]; do - if [[ "$posN" -gt -1 ]]; then - fragment="${content:0:$posN + 1}" - content=${content:$posN + 1} - else - fragment="${content:0:$posR + 1}" - content=${content:$posR + 1} - fi - - moTrimChars trimmed "$fragment" false true " " $'\t' $'\n' $'\r' - - if [[ -n "$trimmed" ]]; then - fragment="$2$fragment" - fi - - result="$result$fragment" - - moFindString posN "$content" $'\n' - moFindString posR "$content" $'\r' - - # If the content ends in a newline, do not indent. - if [[ "$posN" -eq ${#content} ]]; then - # Special clause for \r\n - if [[ "$posR" -eq "$((posN - 1))" ]]; then - posR=-1 - fi - - posN=-1 - fi - - if [[ "$posR" -eq ${#content} ]]; then - posR=-1 - fi - done - - moTrimChars trimmed "$content" false true " " $'\t' - - if [[ -n "$trimmed" ]]; then - content="$2$content" - fi - - result="$result$content" - - local "$1" && moIndirect "$1" "$result" + local "$1" && mo::indirect "$1" "$moContent" } @@ -432,13 +288,13 @@ moIndentLines() { # Examples # # callFunc () { -# local "$1" && moIndirect "$1" "the value" +# local "$1" && mo::indirect "$1" "the value" # } # callFunc dest # echo "$dest" # writes "the value" # # Returns nothing. -moIndirect() { +mo::indirect() { unset -v "$1" printf -v "$1" '%s' "$2" } @@ -453,13 +309,13 @@ moIndirect() { # # callFunc () { # local myArray=(one two three) -# local "$1" && moIndirectArray "$1" "${myArray[@]}" +# local "$1" && mo::indirectArray "$1" "${myArray[@]}" # } # callFunc dest # echo "${dest[@]}" # writes "one two three" # # Returns nothing. -moIndirectArray() { +mo::indirectArray() { unset -v "$1" # IFS must be set to a string containing space or unset in order for @@ -470,6 +326,806 @@ moIndirectArray() { } +# Internal: Find the first index of a substring. If not found, sets the +# index to -1. +# +# $1 - Destination variable for the index +# $2 - Haystack +# $3 - Needle +# +# Returns nothing. +mo::findString() { + local moPos moString + + moString=${2%%"$3"*} + [[ "$moString" == "$2" ]] && moPos=-1 || moPos=${#moString} + local "$1" && mo::indirect "$1" "$moPos" +} + + +# Internal: Split a larger string into an array of at most 2 elements. +# +# $1 - Destination variable +# $2 - String to split +# $3 - Starting delimiter +# +# Returns nothing. +mo::split() { + local moPos moResult + + moResult=("$2") + mo::findString moPos "${moResult[0]}" "$3" + + if [[ "$moPos" -ne -1 ]]; then + # The first delimiter was found + moResult[1]=${moResult[0]:$moPos + ${#3}} + moResult[0]=${moResult[0]:0:$moPos} + fi + + local "$1" && mo::indirectArray "$1" "${moResult[@]}" +} + + +# Internal: Trim leading characters +# +# $1 - Name of destination variable +# $2 - The string +# +# Returns nothing. +mo::trim() { + local moContent moLast moR moN moT + + moContent=$2 + moLast="" + moR=$'\r' + moN=$'\n' + moT=$'\t' + + while [[ "$moContent" != "$moLast" ]]; do + moLast=$moContent + moContent=${moContent# } + moContent=${moContent#$moR} + moContent=${moContent#$moN} + moContent=${moContent#$moT} + done + + local "$1" && mo::indirect "$1" "$moContent" +} + + +# Internal: Remove whitespace and content after whitespace +# +# $1 - Name of the destination variable +# $2 - The string to chomp +# +# Returns nothing. +mo::chomp() { + local moTemp moR moN moT + + moR=$'\r' + moN=$'\n' + moT=$'\t' + moTemp=${2%% *} + moTemp=${moTemp%%$moR*} + moTemp=${moTemp%%$moN*} + moTemp=${moTemp%%$moT*} + + local "$1" && mo::indirect "$1" "$moTemp" +} + +# Internal: Parse a block of text, writing the result to stdout. Interpolates +# mustache tags. +# +# $1 - Destination variable name to send an array +# $2 - Block of text to change +# $3 - Current name (the variable NAME for what {{.}} means) +# $4 - Current block name +# $5 - Open delimiter ("{{") +# $6 - Close delimiter ("}}") +# $7 - Fast mode (skip to end of block) if non-empty +# +# +# Array has the following elements +# [0] - Parsed content +# [1] - Unparsed content after the closing tag +# +# Returns nothing. +mo::parse() { + local moContent moCurrent moOpenDelimiter moCloseDelimieter moResult moSplit moParseChunk moFastMode moStandaloneContent + moContent=$2 + moCurrent=$3 + moCurrentBlock=$4 + moOpenDelimiter=$5 + moCloseDelimiter=$6 + moFastMode=$7 + moResult="" + moRemainder="" + + # This is a trick to make the standalone tag detection believe it's on a + # new line because there's no other way to easily tell the difference + # between a lone tag on a line and two tags where the first one evaluated + # to an empty string. + moStandaloneContent=$'\n' + mo::debug "Starting parse, current: $moCurrent, ending tag: $moCurrentBlock, fast: $moFastMode" + + while [[ "${#moContent}" -gt 0 ]]; do + # Both escaped and unescaped content are treated the same. + mo::split moSplit "$moContent" "$moOpenDelimiter" + + if [[ "${#moSplit[@]}" -gt 1 ]]; then + moResult="$moResult${moSplit[0]}" + moStandaloneContent="$moStandaloneContent${moSplit[0]}" + mo::trim moContent "${moSplit[1]}" + + case $moContent in + '#'*) + # Loop, if/then, or pass content through function + mo::parseBlock moParseChunk "$moResult" "$moContent" "$moCurrent" "$moOpenDelimiter" "$moCloseDelimiter" false "$moStandaloneContent" + ;; + + '>'*) + # Load partial - get name of file relative to cwd + mo::parsePartial moParseChunk "$moResult" "$moContent" "$moCurrent" "$moCloseDelimiter" "$moFastMode" "$moStandaloneContent" + ;; + + '/'*) + # Closing tag + mo::parseCloseTag moParseChunk "$moResult" "$moContent" "$moCurrent" "$moCloseDelimiter" "$moCurrentBlock" "$moStandaloneContent" + moRemainder=${moParseChunk[2]} + ;; + + '^'*) + # Display section if named thing does not exist + mo::parseBlock moParseChunk "$moResult" "$moContent" "$moCurrent" "$moOpenDelimiter" "$moCloseDelimiter" true "$moStandaloneContent" + ;; + + '!'*) + # Comment - ignore the tag content entirely + mo::parseComment moParseChunk "$moResult" "$moContent" "$moCloseDelimiter" "$moStandaloneContent" + ;; + + '='*) + # Change delimiters + # Any two non-whitespace sequences separated by whitespace. + mo::parseDelimiter moParseChunk "$moResult" "$moContent" "$moCloseDelimiter" "$moStandaloneContent" + moOpenDelimiter=${moParseChunk[2]} + moCloseDelimiter=${moParseChunk[3]} + ;; + + '&'*) + # Unescaped - mo doesn't escape + moContent=${moContent#&} + mo::trim moContent "$moContent" + mo::parseValue moParseChunk "$moResult" "$moContent" "$moCurrent" "$moOpenDelimiter" "$moCloseDelimiter" "$moFastMode" + ;; + + *) + # Normal environment variable, string, subexpression, + # current value, key, or function call + mo::parseValue moParseChunk "$moResult" "$moContent" "$moCurrent" "$moOpenDelimiter" "$moCloseDelimiter" "$moFastMode" + ;; + esac + + moResult=${moParseChunk[0]} + moContent=${moParseChunk[1]} + + # Do not employ the trick after the first tag gets processed (see above) + moStandaloneContent='' + else + moResult="$moResult$moContent" + moContent="" + fi + done + + local "$1" && mo::indirectArray "$1" "$moResult" "$moRemainder" +} + + +# Internal: Handle parsing a block +# +# $1 - Destination variable name, will be set to an array +# $2 - Previously parsed +# $3 - Content +# $4 - Current name (the variable NAME for what {{.}} means) +# $5 - Open delimiter +# $6 - Close delimiter +# $7 - Invert condition ("true" or "false") +# $8 - Standalone content +# +# The destination value will be an array +# [0] = the result text +# [1] = remaining content to parse, excluding the closing delimiter +# +# Returns nothing +mo::parseBlock() { + local moContent moCurrent moOpenDelimiter moCloseDelimiter moInvertBlock moTag moArgs moTemp moParseResult moResult moPrevious moStandaloneContent moArrayName moArrayIndexes moArrayIndex + + moPrevious=$2 + mo::trim moContent "${3:1}" + moCurrent=$4 + moOpenDelimiter=$5 + moCloseDelimiter=$6 + moInvertBlock=$7 + moStandaloneContent=$8 + mo::parseValueInner moArgs "$moContent" "$moCurrent" "$moCloseDelimiter" + moContent="${moArgs[0]#$moCloseDelimiter}" + moArgs=("${moArgs[@]:1}") + mo::debug "Parsing block: ${moArgs[*]}" + + if [[ "${moArgs[0]}" == "NAME" ]] && mo::isFunction "${moArgs[1]}"; then + if mo::standaloneCheck "$moStandaloneContent" "$moContent"; then + mo::standaloneProcessBefore moPrevious "$moPrevious" + mo::standaloneProcessAfter moContent "$moContent" + fi + + # Get contents of block after parsing + mo::parse moParseResult "$moContent" "$moCurrent" "${moArgs[1]}" "$moOpenDelimiter" "$moCloseDelimiter" "" + + # Pass contents to function + mo::evaluateFunction moResult "${moParseResult[0]}" "${moArgs[@]:1}" + moContent=${moParseResult[1]} + elif [[ "${moArgs[0]}" == "NAME" ]] && mo::isArray "${moArgs[1]}"; then + # Need to interate across array for each element in the array. + if mo::standaloneCheck "$moStandaloneContent" "$moContent"; then + mo::standaloneProcessBefore moPrevious "$moPrevious" + mo::standaloneProcessAfter moContent "$moContent" + fi + + moArrayName=${moArgs[1]} + eval "moArrayIndexes=(\"\${!${moArrayName}[@]}\")" + + if [[ "${#moArrayIndexes[@]}" -lt 1 ]]; then + # No elements. Skip the block processing + mo::parse moParseResult "$moContent" "$moCurrent" "${moArgs[1]}" "$moOpenDelimiter" "$moCloseDelimiter" "FAST-EMPTY" + moResult="" + else + moResult="" + # Process for each element in the array + for moArrayIndex in "${moArrayIndexes[@]}"; do + mo::debug "Iterate over array using element: $moArrayName.$moArrayIndex" + mo::parse moParseResult "$moContent" "$moArrayName.$moArrayIndex" "${moArgs[1]}" "$moOpenDelimiter" "$moCloseDelimiter" "" + moResult="$moResult${moParseResult[0]}" + done + fi + + moContent=${moParseResult[1]} + else + if mo::standaloneCheck "$moStandaloneContent" "$moContent"; then + mo::standaloneProcessBefore moPrevious "$moPrevious" + mo::standaloneProcessAfter moContent "$moContent" + fi + + # Variable, value, or list of mixed things + mo::evaluateListOfSingles moResult "$moCurrent" "${moArgs[@]}" + + if mo::isTruthy "$moResult" "$moInvertBlock"; then + mo::debug "Block is truthy: $moResult" + mo::parse moParseResult "$moContent" "$moCurrent" "${moArgs[1]}" "$moOpenDelimiter" "$moCloseDelimiter" "" + else + mo::debug "Block is falsy: $moResult" + mo::parse moParseResult "$moContent" "$moCurrent" "${moArgs[1]}" "$moOpenDelimiter" "$moCloseDelimiter" "FAST-FALSY" + moParseResult[0]="" + fi + + moResult=${moParseResult[0]} + moContent=${moParseResult[1]} + fi + + local "$1" && mo::indirectArray "$1" "$moPrevious$moResult" "$moContent" +} + + +# Internal: Handle parsing a partial +# +# $1 - Destination variable name, will be set to an array +# $2 - Previously parsed +# $3 - Content +# $4 - Current name (the variable NAME for what {{.}} means) +# $5 - Close delimiter for the current tag +# $6 - Fast mode (skip to end of block) if non-empty +# $7 - Standalone content +# +# The destination value will be an array +# [0] = the result text +# [1] = remaining content to parse, excluding the closing delimiter +# +# Indentation should be applied to the entire partial's contents that are +# returned. Adding indentation is outside the scope of this function. +# +# Returns nothing +mo::parsePartial() { + local moContent moCurrent moCloseDelimiter moFilename moResult moFastMode moPrevious moStandaloneContent + + moPrevious=$2 + mo::trim moContent "${3:1}" + moCurrent=$4 + moCloseDelimiter=$5 + moFastMode=$6 + moStandaloneContent=$7 + mo::chomp moFilename "${moContent%%$moCloseDelimiter*}" + moContent="${moContent#*$moCloseDelimiter}" + + if mo::standaloneCheck "$moStandaloneContent" "$moContent"; then + mo::standaloneProcessBefore moPrevious "$moPrevious" + mo::standaloneProcessAfter moContent "$moContent" + fi + + if [[ -n "$moFastMode" ]]; then + moResult="" + moLen=0 + else + mo::debug "Parsing partial: $moFilename" + + # Execute in subshell to preserve current cwd and environment + moResult=$( + # It would be nice to remove `dirname` and use a function instead, + # but that is difficult when only given filenames. + cd "$(dirname -- "$moFilename")" || exit 1 + echo "$( + mo::contentFile moResult "${moFilename##*/}" || exit 1 + + # Delimiters are reset when loading a new partial + mo::parse moResult "$moResult" "$moCurrent" "" "{{" "}}" "" + + # Fix bash handling of subshells and keep trailing whitespace. + echo -n "${moResult[0]}${moResult[1]}." + )" || exit 1 + ) || exit 1 + moLen=${#moResult} + + if [[ $moLen -gt 0 ]]; then + moLen=$((moLen - 1)) + fi + fi + + local "$1" && mo::indirectArray "$1" "$moPrevious${moResult:0:moLen}" "$moContent" +} + + +# Internal: Handle closing a tag +# +# $1 - Destination variable name, will be set to an array +# $2 - Previous content +# $3 - Content +# $4 - Current name (the variable NAME for what {{.}} means) +# $5 - Close delimiter for the current tag +# $6 - Current block being processed +# $7 - Standalone content +# +# The destination value will be an array +# [0] = the result text ($2) +# [1] = remaining content to parse, excluding the closing delimiter (nothing) +# [2] = Unparsed content outside of the block (the remainder) +# +# Returns nothing. +mo::parseCloseTag() { + local moContent moArgs moCurrent moCloseDelimiter moCurrentBlock moPrevious moStandaloneContent + + moPrevious=$2 + moContent=${3:1} + moCurrent=$4 + moCloseDelimiter=$5 + moCurrentBlock=$6 + moStandaloneContent=$7 + mo::parseValueInner moArgs "$moContent" "$moCurrent" "$moCloseDelimiter" + moContent="${moArgs[0]#$moCloseDelimiter}" + mo::debug "Closing tag: ${moArgs[2]}" + + if mo::standaloneCheck "$moStandaloneContent" "$moContent"; then + mo::standaloneProcessBefore moPrevious "$moPrevious" + mo::standaloneProcessAfter moContent "$moContent" + fi + + if [[ -n "$moCurrentBlock" ]] && [[ "${moArgs[2]}" != "$moCurrentBlock" ]]; then + mo::error "Unexpected close tag: ${moArgs[2]}, expected $moCurrentBlock" + elif [[ -z "$moCurrentBlock" ]]; then + mo::error "Unexpected close tag: ${moArgs[2]}" + fi + + local "$1" && mo::indirectArray "$1" "$moPrevious" "" "$moContent" +} + + +# Internal: Handle parsing a comment +# +# $1 - Destination variable name, will be set to an array +# $2 - Previous content +# $3 - Content +# $4 - Close delimiter for the current tag +# $5 - Standalone content +# +# The destination value will be an array +# [0] = the result text +# [1] = remaining content to parse, excluding the closing delimiter +# +# Returns nothing +mo::parseComment() { + local moContent moCloseDelimiter moStandaloneContent moPrevious moContent moCloseDelimiter + + moPrevious=$2 + moContent=$3 + moCloseDelimiter=$4 + moStandaloneContent=$5 + moContent=${moContent#*$moCloseDelimiter} + mo::debug "Parsing comment" + + if mo::standaloneCheck "$moStandaloneContent" "$moContent"; then + mo::standaloneProcessBefore moPrevious "$moPrevious" + mo::standaloneProcessAfter moContent "$moContent" + fi + + local "$1" && mo::indirectArray "$1" "$moPrevious" "$moContent" +} + + +# Internal: Handle parsing the change of delimiters +# +# $1 - Destination variable name, will be set to an array +# $2 - Previous content +# $3 - Content +# $4 - Close delimiter for the current tag +# $5 - Standalone content +# +# The destination value will be an array +# [0] = the result text +# [1] = remaining content to parse, excluding the closing delimiter +# [2] = new open delimiter +# [3] = new close delimiter +# +# Returns nothing +mo::parseDelimiter() { + local moContent moCloseDelimiter moOpen moClose moPrevious moStandaloneContent + + moPrevious=$2 + mo::trim moContent "${3#=}" + moCloseDelimiter=$4 + moStandaloneContent=$5 + mo::chomp moOpen "$moContent" + moContent=${moContent:${#moOpen}} + mo::trim moContent "$moContent" + moClose="${moContent%%=$moCloseDelimiter*}" + moContent=${moContent#*=$moCloseDelimiter} + mo::debug "Parsing delimiters: $moOpen $moClose" + + if mo::standaloneCheck "$moStandaloneContent" "$moContent"; then + mo::standaloneProcessBefore moPrevious "$moPrevious" + mo::standaloneProcessAfter moContent "$moContent" + fi + + local "$1" && mo::indirectArray "$1" "$moPrevious" "$moContent" "$moOpen" "$moClose" +} + + +# Internal: Handle parsing value or function call +# +# $1 - Destination variable name, will be set to an array +# $2 - Previous content +# $3 - Content +# $4 - Current name (the variable NAME for what {{.}} means) +# $5 - Open delimiter for the current tag +# $6 - Close delimiter for the current tag +# $7 - Fast mode (skip to end of block) if non-empty +# +# The destination value will be an array +# [0] = the result text +# [1] = remaining content to parse, excluding the closing delimiter +# +# Returns nothing +mo::parseValue() { + local moContent moContentOriginal moOpenDelimiter moCurrent moCloseDelimiter moArgs moResult moFastMode moPrevious + + moPrevious=$2 + moContentOriginal=$3 + moCurrent=$4 + moOpenDelimiter=$5 + moCloseDelimiter=$6 + moFastMode=$7 + mo::trim moContent "${moContentOriginal#$moOpenDelimiter}" + + mo::parseValueInner moArgs "$moContent" "$moCurrent" "$moCloseDelimiter" + moContent=${moArgs[0]} + moArgs=("${moArgs[@]:1}") + + if [[ -n "$moFastMode" ]]; then + moResult="" + else + mo::evaluate moResult "$moCurrent" "${moArgs[@]}" + fi + + if [[ "${moContent:0:${#moCloseDelimiter}}" != "$moCloseDelimiter" ]]; then + mo::error "Did not find closing tag near: $moContentOriginal" + fi + + moContent=${moContent:${#moCloseDelimiter}} + + local "$1" && mo::indirectArray "$1" "$moPrevious$moResult" "$moContent" +} + + +# Internal: Handle parsing value or function call inside of delimiters +# +# $1 - Destination variable name, will be set to an array +# $2 - Content +# $3 - Current name (the variable NAME for what {{.}} means) +# $4 - Close delimiter for the current tag +# +# The destination value will be an array +# [0] = remaining content to parse, including the closing delimiter +# [1-*] = a list of argument type, argument name/value +# +# Returns nothing +mo::parseValueInner() { + local moContent moCurrent moCloseDelimiter moArgs moArgResult moResult + + moContent=$2 + moCurrent=$3 + moCloseDelimiter=$4 + moArgs=() + + while [[ "$moContent" != "$moCloseDelimiter"* ]] && [[ "$moContent" != "}"* ]] && [[ "$moContent" != ")"* ]] && [[ -n "$moContent" ]]; do + mo::getArgument moArgResult "$moCurrent" "$moContent" "$moCloseDelimiter" + moArgs=(${moArgs[@]+"${moArgs[@]}"} "${moArgResult[0]}" "${moArgResult[1]}") + mo::trim moContent "${moArgResult[2]}" + done + + mo::debug "Parsed arguments: ${moArgs[*]}" + + local "$1" && mo::indirectArray "$1" "$moContent" ${moArgs[@]+"${moArgs[@]}"} +} + + +# Internal: Retrieve an argument name +# +# $1 - Destination variable name. Will be an array. +# $2 - Content +# $3 - Closing delimiter +# +# The array will have the following elements +# [0] = argument type, "NAME" or "VALUE" +# [1] = argument name or value +# [2] = unparsed content +# +# Returns nothing +mo::getArgument() { + local moContent moCurrent moClosingDelimiter moArg + + moCurrent=$2 + moContent=$3 + moClosingDelimiter=$4 + + case "$moContent" in + '{'*) + mo::getArgumentBrace moArg "$moContent" "$moCurrent" "$moClosingDelimiter" + ;; + + '('*) + mo::getArgumentParenthesis moArg "$moContent" "$moCurrent" "$moClosingDelimiter" + ;; + + '"'*) + mo::getArgumentDoubleQuote moArg "$moContent" + ;; + + "'"*) + mo::getArgumentSingleQuote moArg "$moContent" + ;; + + *) + mo::getArgumentDefault moArg "$moContent" "$moClosingDelimiter" + esac + + mo::debug "Found argument: ${moArg[0]} ${moArg[1]}" + + local "$1" && mo::indirectArray "$1" "${moArg[0]}" "${moArg[1]}" "${moArg[2]}" +} + + +# Internal: Get an argument, which is the result of a subexpression as a VALUE +# +# $1 - Destination variable name, an array with two elements +# $2 - Content +# $3 - Current name (the variable NAME for what {{.}} means) +# $4 - Close delimiter for the current tag +# +# The array has the following elements. +# [0] = argument type, "NAME" or "VALUE" +# [1] = argument name or value +# [2] = unparsed content +# +# Returns nothing. +mo::getArgumentBrace() { + local moResult moContent moCurrent moCloseDelimiter moArgs + + mo::trim moContent "${2:1}" + moCurrent=$3 + moCloseDelimiter=$4 + mo::parseValueInner moResult "$moContent" "$moCurrent" "$moCloseDelimiter" + moContent="${moResult[0]}" + moArgs=("${moResult[@]:1}") + mo::evaluate moResult "$moCurrent" "${moArgs[@]}" + + if [[ "${moContent:0:1}" != "}" ]]; then + mo::error "Unbalanced brace near ${2:0:20}" + fi + + mo::trim moContent "${moContent:1}" + + local "$1" && mo::indirectArray "$1" "VALUE" "${moResult[0]}" "$moContent" +} + + +# Internal: Get an argument, which is the result of a subexpression as a NAME +# +# $1 - Destination variable name, an array with two elements +# $2 - Content +# $3 - Current name (the variable NAME for what {{.}} means) +# $4 - Close delimiter for the current tag +# +# The array has the following elements. +# [0] = argument type, "NAME" or "VALUE" +# [1] = argument name or value +# [2] = unparsed content +# +# Returns nothing. +mo::getArgumentParenthesis() { + local moResult moContent moCurrent moCloseDelimiter + + mo::trim moContent "${2:1}" + moCurrent=$3 + moCloseDelimiter=$4 + mo::parseValueInner moResult "$moContent" "$moCurrent" "$moCloseDelimiter" + moContent="${moResult[1]}" + + if [[ "${moContent:0:1}" != ")" ]]; then + mo::error "Unbalanced parenthesis near ${2:0:20}" + fi + + mo::trim moContent "${moContent:1}" + + local "$1" && mo::indirectArray "$1" "NAME" "${moResult[0]}" "$moContent" +} + + +# Internal: Get an argument in a double quoted string +# +# $1 - Destination variable name, an array with two elements +# $2 - Content +# +# The array has the following elements. +# [0] = argument type, "NAME" or "VALUE" +# [1] = argument name or value +# [2] = unparsed content +# +# Returns nothing. +mo::getArgumentDoubleQuote() { + local moTemp moContent + + moTemp="" + moContent=${2:1} + + while [[ "${moContent:0:1}" != '"' ]]; do + case "$moContent" in + \\n) + moTemp="$moTemp"$'\n' + moContent=${moContent:2} + ;; + + \\r) + moTemp="$moTemp"$'\r' + moContent=${moContent:2} + ;; + + \\t) + moTemp="$moTemp"$'\t' + moContent=${moContent:2} + ;; + + \\*) + moTemp="$moTemp${moContent:1:1}" + moContent=${moContent:2} + ;; + + *) + moTemp="$moTemp${moContent:0:1}" + moContent=${moContent:1} + ;; + esac + + if [[ -z "$moContent" ]]; then + mo::error "Found starting double quote but no closing double quote" + fi + done + + mo::debug "Parsed double quoted value: $moTemp" + + local "$1" && mo::indirectArray "$1" "VALUE" "$moTemp" "${moContent:1}" +} + + +# Internal: Get an argument in a single quoted string +# +# $1 - Destination variable name, an array with two elements +# $2 - Content +# +# The array has the following elements. +# [0] = argument type, "NAME" or "VALUE" +# [1] = argument name or value +# [2] = unparsed content +# +# Returns nothing. +mo::getArgumentSingleQuote() { + local moTemp moContent + + moTemp="" + moContent=${2:1} + + while [[ "${moContent:0:1}" != "'" ]]; do + moTemp="$moTemp${moContent:0:1}" + moContent=${moContent:1} + + if [[ -z "$moContent" ]]; then + mo::error "Found starting single quote but no closing single quote" + fi + done + + mo::debug "Parsed single quoted value: $moTemp" + + local "$1" && mo::indirectArray "$1" "VALUE" "$moTemp" "${moContent:1}" +} + + +# Internal: Get an argument that is a simple variable name +# +# $1 - Destination variable name, an array with two elements +# $2 - Content +# +# The array has the following elements. +# [0] = argument type, "NAME" or "VALUE" +# [1] = argument name or value +# [2] = unparsed content +# +# Returns nothing. +mo::getArgumentDefault() { + local moTemp moContent + + moTemp=$2 + mo::chomp moTemp "${moTemp%%$3*}" + moTemp=${moTemp%%)*} + moTemp=${moTemp%%\}*} + moContent=${2:${#moTemp}} + mo::debug "Parsed default argument: $moTemp" + + local "$1" && mo::indirectArray "$1" "NAME" "$moTemp" "$moContent" +} + + +# Internal: Determine if the given name is a defined function. +# +# $1 - Function name to check +# +# Be extremely careful. Even if strict mode is enabled, it is not honored +# in newer versions of Bash. Any errors that crop up here will not be +# caught automatically. +# +# Examples +# +# moo () { +# echo "This is a function" +# } +# if mo::isFunction moo; then +# echo "moo is a defined function" +# fi +# +# Returns 0 if the name is a function, 1 otherwise. +mo::isFunction() { + if declare -F "$1" &> /dev/null; then + return 0 + fi + + return 1 +} + + # Internal: Determine if a given environment variable exists and if it is # an array. # @@ -488,7 +1144,7 @@ moIndirectArray() { # fi # # Returns 0 if the name is not empty, 1 otherwise. -moIsArray() { +mo::isArray() { # Namespace this variable so we don't conflict with what we're testing. local moTestResult @@ -500,91 +1156,200 @@ moIsArray() { } -# Internal: Determine if the given name is a defined function. +# Internal: Determine if a variable is assigned, even if it is assigned an empty +# value. # -# $1 - Function name to check +# $1 - Variable name to check. # -# Be extremely careful. Even if strict mode is enabled, it is not honored -# in newer versions of Bash. Any errors that crop up here will not be -# caught automatically. -# -# Examples -# -# moo () { -# echo "This is a function" -# } -# if moIsFunction moo; then -# echo "moo is a defined function" -# fi -# -# Returns 0 if the name is a function, 1 otherwise. -moIsFunction() { - local functionList functionName - - functionList=$(declare -F) - # shellcheck disable=SC2206 - functionList=( ${functionList//declare -f /} ) - - for functionName in "${functionList[@]}"; do - if [[ "$functionName" == "$1" ]]; then - return 0 - fi - done - - return 1 +# Returns true (0) if the variable is set, 1 if the variable is unset. +mo::isVarSet() { + [[ "${!1-a}" == "${!1-b}" ]] } -# Internal: Determine if the tag is a standalone tag based on whitespace -# before and after the tag. +# Internal: Determine if a value is considered truthy. # -# Passes back a string containing two numbers in the format "BEFORE AFTER" -# like "27 10". It indicates the number of bytes remaining in the "before" -# string (27) and the number of bytes to trim in the "after" string (10). -# Useful for string manipulation: +# $1 - The value to test +# $2 - Invert the value, either "true" or "false" # -# $1 - Variable to set for passing data back -# $2 - Content before the tag -# $3 - Content after the tag -# $4 - true/false: is this the beginning of the content? +# Returns true (0) if truthy, 1 otherwise. +mo::isTruthy() { + local moTruthy + + moTruthy=true + + if [[ -z "${1-}" ]]; then + moTruthy=false + elif [[ -n "${MO_FALSE_IS_EMPTY-}" ]] && [[ "${1-}" == "false" ]]; then + moTruthy=false + fi + + # XOR the results + # moTruthy inverse desiredResult + # true false true + # true true false + # false false false + # false true true + if [[ "$moTruthy" == "$2" ]]; then + return 1 + fi + + return 0 +} + + +# Internal: Convert an argument list to values # -# Examples +# $1 - Destination variable name +# $2 - Current name (the variable NAME for what {{.}} means) +# $3-$* - A list of argument types and argument name/value. # -# moIsStandalone RESULT "$before" "$after" false || return 0 -# RESULT_ARRAY=( $RESULT ) -# echo "${before:0:${RESULT_ARRAY[0]}}...${after:${RESULT_ARRAY[1]}}" +# Sample call: +# +# mo::evaluate dest NAME username VALUE abc123 # # Returns nothing. -moIsStandalone() { - local afterTrimmed beforeTrimmed char +mo::evaluate() { + local moResult moTarget moCurrent moFunction moArgs moTemp - moTrimChars beforeTrimmed "$2" false true " " $'\t' - moTrimChars afterTrimmed "$3" true false " " $'\t' - char=$((${#beforeTrimmed} - 1)) - char=${beforeTrimmed:$char} + moTarget=$1 + moCurrent=$2 + shift 2 - # If the content before didn't end in a newline - if [[ "$char" != $'\n' ]] && [[ "$char" != $'\r' ]]; then - # and there was content or this didn't start the file - if [[ -n "$char" ]] || ! $4; then - # then this is not a standalone tag. - return 1 + if [[ "$1" == "NAME" ]] && mo::isFunction "$2"; then + # Special case - if the first argument is a function, then the rest are + # passed to the function. + moFunction=$2 + mo::evaluateFunction moResult "" "${@:2}" + else + mo::evaluateListOfSingles moResult "$moCurrent" ${@+"$@"} + fi + + local "$moTarget" && mo::indirect "$moTarget" "$moResult" +} + + +# Internal: Convert an argument list to individual values. +# +# $1 - Destination variable name +# $2 - Current name (the variable NAME for what {{.}} means) +# $3-$* - A list of argument types and argument name/value. +# +# This assumes each value is separate from the rest. In contrast, mo::evaluate +# will pass all arguments to a function if the first value is a function. +# +# Sample call: +# +# mo::evaluateListOfSingles dest NAME username VALUE abc123 +# +# Returns nothing. +mo::evaluateListOfSingles() { + local moResult moTarget moTemp moCurrent + + moTarget=$1 + moCurrent=$2 + shift 2 + moResult="" + + while [[ $# -gt 1 ]]; do + mo::evaluateSingle moTemp "$moCurrent" "$1" "$2" + moResult="$moResult$moTemp" + shift 2 + done + + mo::debug "Evaluated list of singles: $moResult" + + local "$moTarget" && mo::indirect "$moTarget" "$moResult" +} + + +# Internal: Evaluate a single argument +# +# $1 - Name of variable for result +# $2 - Current name (the variable NAME for what {{.}} means) +# $3 - Type of argument, either NAME or VALUE +# $4 - Argument +# +# Returns nothing +mo::evaluateSingle() { + local moResult moCurrent moVarNameParts moType moArg + + moCurrent=$2 + moType=$3 + moArg=$4 + mo::debug "Evaluating $moType: $moArg" + + if [[ "$moType" == "VALUE" ]]; then + moResult=$moArg + elif [[ "$moArg" == "." ]]; then + mo::evaluateVariable moResult "$moCurrent" + elif [[ "$moArg" == "@key" ]]; then + mo::evaluateKey moResult "$moCurrent" + elif mo::isFunction "$moArg"; then + mo::evaluateFunction moResult "" "$moArg" + else + mo::split moVarNameParts "$moArg" . + mo::evaluateVariable moResult "$moArg" + fi + + local "$1" && mo::indirect "$1" "$moResult" +} + + +# Internal: Return the value for @key based on current's name +# +# $1 - Name of variable for result +# $2 - Current name (the variable NAME for what {{.}} means) +# +# Returns nothing +mo::evaluateKey() { + local moCurrent moResult + + moCurrent=$2 + + if [[ "$moCurrent" == *.* ]]; then + moResult="${moCurrent#*.}" + else + moResult="${moCurrent}" + fi + + local "$1" && mo::indirect "$1" "$moResult" +} + + +# Internal: Handle a variable name +# +# $1 - Destination variable name +# $2 - Variable name +# +# Returns nothing. +mo::evaluateVariable() { + local moResult moCurrent moArg moNameParts moJoined moKey moValue + + moArg=$2 + moResult="" + mo::split moNameParts "$moArg" . + + if [[ -z "${moNameParts[1]-}" ]]; then + if mo::isArray "$moArg"; then + eval mo::join moResult "," "\${$moArg[@]}" + else + # shellcheck disable=SC2031 + if mo::isVarSet "$moArg"; then + moResult="${!moArg}" + elif [[ -n "${MO_FAIL_ON_UNSET-}" ]]; then + mo::error "Environment variable not set: $moArg" + fi + fi + else + if mo::isArray "${moNameParts[0]}"; then + eval "moResult=\"\${${moNameParts[0]}[${moNameParts[1]%%.*}]}\"" + else + mo::error "Unable to index a scalar as an array: $moArg" fi fi - char=${afterTrimmed:0:1} - - # If the content after doesn't start with a newline and it is something - if [[ "$char" != $'\n' ]] && [[ "$char" != $'\r' ]] && [[ -n "$char" ]]; then - # then this is not a standalone tag. - return 2 - fi - - if [[ "$char" == $'\r' ]] && [[ "${afterTrimmed:1:1}" == $'\n' ]]; then - char="$char"$'\n' - fi - - local "$1" && moIndirect "$1" "$((${#beforeTrimmed})) $((${#3} + ${#char} - ${#afterTrimmed}))" + local $1 && mo::indirect "$1" "$moResult" } @@ -607,508 +1372,161 @@ moJoin() { result="$result$joiner$part" done - local "$target" && moIndirect "$target" "$result" + local "$target" && mo::indirect "$target" "$result" } -# Internal: Read a file into a variable. +# Internal: Call a function. # -# $1 - Variable name to receive the file's content -# $2 - Filename to load - if empty, defaults to /dev/stdin +# $1 - Variable for output +# $2 - Content to pass +# $3 - Function to call +# $4-$* - Additional arguments as list of type, value/name # # Returns nothing. -moLoadFile() { - local content len +mo::evaluateFunction() { + local moArgs moContent moFunctionArgs moFunctionResult moTarget moFunction moArgsSafe moTemp - # The subshell removes any trailing newlines. We forcibly add - # a dot to the content to preserve all newlines. - # As a future optimization, it would be worth considering removing - # cat and replacing this with a read loop. + moTarget=$1 + moContent=$2 + moFunction=$3 + shift 3 + moArgs=() - content=$(cat -- "${2:-/dev/stdin}" && echo '.') || return 1 - len=$((${#content} - 1)) - content=${content:0:$len} # Remove last dot - - local "$1" && moIndirect "$1" "$content" -} - - -# Internal: Process a chunk of content some number of times. Writes output -# to stdout. -# -# $1 - Content to parse repeatedly -# $2 - Tag prefix (context name) -# $3-@ - Names to insert into the parsed content -# -# Returns nothing. -moLoop() { - local content context contextBase - - content=$1 - contextBase=$2 - shift 2 - - while [[ "${#@}" -gt 0 ]]; do - moFullTagName context "$contextBase" "$1" - moParse "$content" "$context" false - shift - done -} - - -# Internal: Parse a block of text, writing the result to stdout. -# -# $1 - Block of text to change -# $2 - Current name (the variable NAME for what {{.}} means) -# $3 - true when no content before this, false otherwise -# -# Returns nothing. -moParse() { - # Keep naming variables mo* here to not overwrite needed variables - # used in the string replacements - local moArgs moBlock moContent moCurrent moIsBeginning moNextIsBeginning moTag moKey - - moCurrent=$2 - moIsBeginning=$3 - - # Find open tags - moSplit moContent "$1" '{{' '}}' - - while [[ "${#moContent[@]}" -gt 1 ]]; do - moTrimWhitespace moTag "${moContent[1]}" - moNextIsBeginning=false - - case $moTag in - '#'*) - # Loop, if/then, or pass content through function - # Sets context - moStandaloneAllowed moContent "${moContent[@]}" "$moIsBeginning" - moTrimWhitespace moTag "${moTag:1}" - - # Split arguments from the tag name. Arguments are passed to - # functions. - moArgs=$moTag - moTag=${moTag%% *} - moTag=${moTag%%$'\t'*} - moArgs=${moArgs:${#moTag}} - moFindEndTag moBlock "$moContent" "$moTag" - moFullTagName moTag "$moCurrent" "$moTag" - - if moTest "$moTag"; then - # Show / loop / pass through function - if moIsFunction "$moTag"; then - moCallFunction moContent "$moTag" "${moBlock[0]}" "$moArgs" - moParse "$moContent" "$moCurrent" false - moContent="${moBlock[2]}" - elif moIsArray "$moTag"; then - eval "moLoop \"\${moBlock[0]}\" \"$moTag\" \"\${!${moTag}[@]}\"" - else - moParse "${moBlock[0]}" "$moCurrent" true - fi - fi - - moContent="${moBlock[2]}" - ;; - - '>'*) - # Load partial - get name of file relative to cwd - moPartial moContent "${moContent[@]}" "$moIsBeginning" "$moCurrent" - moNextIsBeginning=${moContent[1]} - moContent=${moContent[0]} - ;; - - '/'*) - # Closing tag - If hit in this loop, we simply ignore - # Matching tags are found in moFindEndTag - moStandaloneAllowed moContent "${moContent[@]}" "$moIsBeginning" - ;; - - '^'*) - # Display section if named thing does not exist - moStandaloneAllowed moContent "${moContent[@]}" "$moIsBeginning" - moTrimWhitespace moTag "${moTag:1}" - moFindEndTag moBlock "$moContent" "$moTag" - moFullTagName moTag "$moCurrent" "$moTag" - - if ! moTest "$moTag"; then - moParse "${moBlock[0]}" "$moCurrent" false "$moCurrent" - fi - - moContent="${moBlock[2]}" - ;; - - '!'*) - # Comment - ignore the tag content entirely - # Trim spaces/tabs before the comment - moStandaloneAllowed moContent "${moContent[@]}" "$moIsBeginning" - ;; - - .) - # Current content (environment variable or function) - moStandaloneDenied moContent "${moContent[@]}" - moShow "$moCurrent" "$moCurrent" - ;; - - '=') - # Change delimiters - # Any two non-whitespace sequences separated by whitespace. - # This tag is ignored. - moStandaloneAllowed moContent "${moContent[@]}" "$moIsBeginning" - ;; - - '{'*) - # Unescaped - split on }}} not }} - moStandaloneDenied moContent "${moContent[@]}" - moContent="${moTag:1}"'}}'"$moContent" - moSplit moContent "$moContent" '}}}' - moTrimWhitespace moTag "${moContent[0]}" - moArgs=$moTag - moTag=${moTag%% *} - moTag=${moTag%%$'\t'*} - moArgs=${moArgs:${#moTag}} - moFullTagName moTag "$moCurrent" "$moTag" - moContent=${moContent[1]} - - # Now show the value - # Quote moArgs here, do not quote it later. - moShow "$moTag" "$moCurrent" "$moArgs" - ;; - - '&'*) - # Unescaped - moStandaloneDenied moContent "${moContent[@]}" - moTrimWhitespace moTag "${moTag:1}" - moFullTagName moTag "$moCurrent" "$moTag" - moShow "$moTag" "$moCurrent" - ;; - - '@key') - # Special vars - moStandaloneDenied moContent "${moContent[@]}" - # Current content (environment variable or function) - if [[ "$moCurrent" == *.* ]]; then - echo -n "${moCurrent#*.}" - else - echo -n "$moCurrent" - fi - ;; - - *) - # Normal environment variable or function call - moStandaloneDenied moContent "${moContent[@]}" - moArgs=$moTag - moTag=${moTag%% *} - moTag=${moTag%%$'\t'*} - moArgs=${moArgs:${#moTag}} - moFullTagName moTag "$moCurrent" "$moTag" - - # Quote moArgs here, do not quote it later. - moShow "$moTag" "$moCurrent" "$moArgs" - ;; - esac - - moIsBeginning=$moNextIsBeginning - moSplit moContent "$moContent" '{{' '}}' + while [[ $# -gt 1 ]]; do + mo::evaluateSingle moTemp "$moCurrent" "$1" "$2" + moArgs=(${moArgs[@]+"${moArgs[@]}"} "$moTemp") + shift 2 done - echo -n "${moContent[0]}" -} - - -# Internal: Process a partial. -# -# Indentation should be applied to the entire partial. -# -# This sends back the "is beginning" flag because the newline after a -# standalone partial is consumed. That newline is very important in the middle -# of content. We send back this flag to reset the processing loop's -# `moIsBeginning` variable, so the software thinks we are back at the -# beginning of a file and standalone processing continues to work. -# -# Prefix all variables. -# -# $1 - Name of destination variable. Element [0] is the content, [1] is the -# true/false flag indicating if we are at the beginning of content. -# $2 - Content before the tag that was not yet written -# $3 - Tag content -# $4 - Content after the tag -# $5 - true/false: is this the beginning of the content? -# $6 - Current context name -# -# Returns nothing. -moPartial() { - # Namespace variables here to prevent conflicts. - local moContent moFilename moIndent moIsBeginning moPartial moStandalone moUnindented - - if moIsStandalone moStandalone "$2" "$4" "$5"; then + # shellcheck disable=SC2031 + if [[ -n "${MO_ALLOW_FUNCTION_ARGUMENTS-}" ]]; then + # Intentionally remove all function arguments # shellcheck disable=SC2206 - moStandalone=( $moStandalone ) - echo -n "${2:0:${moStandalone[0]}}" - moIndent=${2:${moStandalone[0]}} - moContent=${4:${moStandalone[1]}} - moIsBeginning=true + moArgsSafe=() else - moIndent="" - echo -n "$2" - moContent=$4 - moIsBeginning=$5 + moArgsSafe=(${moArgs[@]+"${moArgs[@]}"}) fi - moTrimWhitespace moFilename "${3:1}" - - # Execute in subshell to preserve current cwd and environment - ( - # It would be nice to remove `dirname` and use a function instead, - # but that's difficult when you're only given filenames. - cd "$(dirname -- "$moFilename")" || exit 1 - moUnindented="$( - moLoadFile moPartial "${moFilename##*/}" || exit 1 - moParse "${moPartial}" "$6" true - - # Fix bash handling of subshells and keep trailing whitespace. - # This is removed in moIndentLines. - echo -n "." - )" || exit 1 - moIndentLines moPartial "$moIndent" "$moUnindented" - echo -n "$moPartial" - ) || exit 1 - - # If this is a standalone tag, the trailing newline after the tag is - # removed and the contents of the partial are added, which typically - # contain a newline. We need to send a signal back to the processing - # loop that the moIsBeginning flag needs to be turned on again. - # - # [0] is the content, [1] is that flag. - local "$1" && moIndirectArray "$1" "$moContent" "$moIsBeginning" -} - - -# Internal: Show an environment variable or the output of a function to -# stdout. -# -# Limit/prefix any variables used. -# -# $1 - Name of environment variable or function -# $2 - Current context -# $3 - Arguments string if $1 is a function -# -# Returns nothing. -moShow() { - # Namespace these variables - local moJoined moNameParts moContent - - if moIsFunction "$1"; then - moCallFunction moContent "$1" "" "$3" - moParse "$moContent" "$2" false - return 0 - fi - - moSplit moNameParts "$1" "." - - if [[ -z "${moNameParts[1]-}" ]]; then - if moIsArray "$1"; then - eval moJoin moJoined "," "\${$1[@]}" - echo -n "$moJoined" - else - # shellcheck disable=SC2031 - if moTestVarSet "$1"; then - echo -n "${!1}" - elif [[ -n "${MO_FAIL_ON_UNSET-}" ]]; then - echo "Env variable not set: $1" >&2 - exit 1 - fi + mo::debug "Calling function: $moFunction ${moArgs[*]}" + moContent=$(echo -n "$moContent" | MO_FUNCTION_ARGS=(${moArgs[@]+"${moArgs[@]}"}) eval "$moFunction" ${moArgsSafe[@]+"${moArgsSafe[@]}"}) || { + moFunctionResult=$? + # shellcheck disable=SC2031 + if [[ -n "${MO_FAIL_ON_FUNCTION-}" && "$moFunctionResult" != 0 ]]; then + mo::error "Function '$moFunction' with args (${moArgs[@]+"${moArgs[@]}"}) failed with status code $moFunctionResult" "$moFunctionResult" fi - else - # Further subindexes are disallowed - eval "echo -n \"\${${moNameParts[0]}[${moNameParts[1]%%.*}]}\"" - fi + } + + # shellcheck disable=SC2031 + local "$1" && mo::indirect "$1" "$moContent" } -# Internal: Split a larger string into an array. +# Internal: Check if a tag appears to have only whitespace before it and after +# it on a line. There must be a new line before (see the trick in mo::parse) +# and there must be a newline after or the end of a string +# +# $1 - Standalone content that was processed in this loop +# $2 - Content after the tag +# +# Returns 0 if this is a standalone tag, 1 otherwise. +mo::standaloneCheck() { + local moContent moN moR moT + + moN=$'\n' + moR=$'\r' + moT=$'\t' + + # Check the content before + moContent=${1//$moR/$moN} + + if [[ "$moContent" != *"$moN"* ]]; then + mo::debug "Not a standalone tag - no newline before" + return 1 + fi + + moContent=${moContent##*$moN} + moContent=${moContent//$moT/} + moContent=${moContent// /} + + if [[ -n "$moContent" ]]; then + mo::debug "Not a standalone tag - non-whitespace detected before tag" + return 1 + fi + + # Check the content after + moContent=${2//$moR/$moN} + moContent=${moContent%%$moN*} + moContent=${moContent//$moT/} + moContent=${moContent// /} + + if [[ -n "$moContent" ]]; then + mo::debug "Not a standalone tag - non-whitespace detected after tag" + return 1 + fi + + return 0 +} + + +# Internal: Process content before a tag to remove whitespace but not the newline. # # $1 - Destination variable -# $2 - String to split -# $3 - Starting delimiter -# $4 - Ending delimiter (optional) +# $2 - Content # # Returns nothing. -moSplit() { - local pos result +mo::standaloneProcessBefore() { + local moContent moLast moT - result=( "$2" ) - moFindString pos "${result[0]}" "$3" + moContent=$2 + moT=$'\t' + moLast= - if [[ "$pos" -ne -1 ]]; then - # The first delimiter was found - result[1]=${result[0]:$pos + ${#3}} - result[0]=${result[0]:0:$pos} + mo::debug "Standalone tag - processing content before tag" - if [[ -n "${4-}" ]]; then - moFindString pos "${result[1]}" "$4" - - if [[ "$pos" -ne -1 ]]; then - # The second delimiter was found - result[2]="${result[1]:$pos + ${#4}}" - result[1]="${result[1]:0:$pos}" - fi - fi - fi - - local "$1" && moIndirectArray "$1" "${result[@]}" -} - - -# Internal: Handle the content for a standalone tag. This means removing -# whitespace (not newlines) before a tag and whitespace and a newline after -# a tag. That is, assuming, that the line is otherwise empty. -# -# $1 - Name of destination "content" variable. -# $2 - Content before the tag that was not yet written -# $3 - Tag content (not used) -# $4 - Content after the tag -# $5 - true/false: is this the beginning of the content? -# -# Returns nothing. -moStandaloneAllowed() { - local bytes - - if moIsStandalone bytes "$2" "$4" "$5"; then - # shellcheck disable=SC2206 - bytes=( $bytes ) - echo -n "${2:0:${bytes[0]}}" - local "$1" && moIndirect "$1" "${4:${bytes[1]}}" - else - echo -n "$2" - local "$1" && moIndirect "$1" "$4" - fi -} - - -# Internal: Handle the content for a tag that is never "standalone". No -# adjustments are made for newlines and whitespace. -# -# $1 - Name of destination "content" variable. -# $2 - Content before the tag that was not yet written -# $3 - Tag content (not used) -# $4 - Content after the tag -# -# Returns nothing. -moStandaloneDenied() { - echo -n "$2" - local "$1" && moIndirect "$1" "$4" -} - - -# Internal: Determines if the named thing is a function or if it is a -# non-empty environment variable. When MO_FALSE_IS_EMPTY is set to a -# non-empty value, then "false" is also treated is an empty value. -# -# Do not use variables without prefixes here if possible as this needs to -# check if any name exists in the environment -# -# $1 - Name of environment variable or function -# $2 - Current value (our context) -# MO_FALSE_IS_EMPTY - When set to a non-empty value, this will say the -# string value "false" is empty. -# -# Returns 0 if the name is not empty, 1 otherwise. When MO_FALSE_IS_EMPTY -# is set, this returns 1 if the name is "false". -moTest() { - # Test for functions - moIsFunction "$1" && return 0 - - if moIsArray "$1"; then - # Arrays must have at least 1 element - eval "[[ \"\${#${1}[@]}\" -gt 0 ]]" && return 0 - else - # If MO_FALSE_IS_EMPTY is set, then return 1 if the value of - # the variable is "false". - # shellcheck disable=SC2031 - [[ -n "${MO_FALSE_IS_EMPTY-}" ]] && [[ "${!1-}" == "false" ]] && return 1 - - # Environment variables must not be empty - [[ -n "${!1-}" ]] && return 0 - fi - - return 1 -} - -# Internal: Determine if a variable is assigned, even if it is assigned an empty -# value. -# -# $1 - Variable name to check. -# -# Returns true (0) if the variable is set, 1 if the variable is unset. -moTestVarSet() { - [[ "${!1-a}" == "${!1-b}" ]] -} - - -# Internal: Trim the leading whitespace only. -# -# $1 - Name of destination variable -# $2 - The string -# $3 - true/false - trim front? -# $4 - true/false - trim end? -# $5-@ - Characters to trim -# -# Returns nothing. -moTrimChars() { - local back current front last target varName - - target=$1 - current=$2 - front=$3 - back=$4 - last="" - shift 4 # Remove target, string, trim front flag, trim end flag - - while [[ "$current" != "$last" ]]; do - last=$current - - for varName in "$@"; do - $front && current="${current/#$varName}" - $back && current="${current/%$varName}" - done + while [[ "$moLast" != "$moContent" ]]; do + moLast=$moContent + moContent=${moContent% } + moContent=${moContent%$moT} done - local "$target" && moIndirect "$target" "$current" + local "$1" && mo::indirect "$1" "$moContent" } -# Internal: Trim leading and trailing whitespace from a string. +# Internal: Process content after a tag to remove whitespace including a single newline. # -# $1 - Name of variable to store trimmed string -# $2 - The string +# $1 - Destination variable +# $2 - Content # # Returns nothing. -moTrimWhitespace() { - local result +mo::standaloneProcessAfter() { + local moContent moLast moT moR moN - moTrimChars result "$2" true true $'\r' $'\n' $'\t' " " - local "$1" && moIndirect "$1" "$result" -} + moContent=$2 + moT=$'\t' + moR=$'\r' + moN=$'\n' + moLast= + mo::debug "Standalone tag - processing content after tag" -# Internal: Displays the usage for mo. Pulls this from the file that -# contained the `mo` function. Can only work when the right filename -# comes is the one argument, and that only happens when `mo` is called -# with `$0` set to this file. -# -# $1 - Filename that has the help message -# -# Returns nothing. -moUsage() { - grep '^#/' "${MO_ORIGINAL_COMMAND}" | cut -c 4- - echo "" - echo "MO_VERSION=$MO_VERSION" + while [[ "$moLast" != "$moContent" ]]; do + moLast=$moContent + moContent=${moContent# } + moContent=${moContent#$moT} + done + + moContent=${moContent#$moR} + moContent=${moContent#$moN} + + local "$1" && mo::indirect "$1" "$moContent" } # Save the original command's path for usage later MO_ORIGINAL_COMMAND="$(cd "${BASH_SOURCE[0]%/*}" || exit 1; pwd)/${BASH_SOURCE[0]##*/}" -MO_VERSION="2.4.1" +MO_VERSION="3.0.0" # If sourced, load all functions. # If executed, perform the actions as expected. diff --git a/run-tests b/run-tests index b1a0331..be45782 100755 --- a/run-tests +++ b/run-tests @@ -1,45 +1,116 @@ #!/usr/bin/env bash +testCase() { + echo "Input: $1" + echo "Expected: $2" +} -cd "${0%/*}" || exit 1 +indirect() { + unset -v "$1" + printf -v "$1" '%s' "$2" +} -# shellcheck disable=SC1091 -. ./mo -PASS=0 -FAIL=0 +getValue() { + local name temp len hardSpace -for TEST in tests/*.expected; do - export BASE="${TEST%.expected}" - export MO_FALSE_IS_EMPTY= + name=$2 + hardSpace=" " - echo -n "$BASE ... " + if declare -f "$name" &> /dev/null; then + temp=$("$name"; echo -n "$hardSpace") + len=$((${#temp} - 1)) - ( - if [[ -f "${BASE}.sh" ]]; then - # Run a shell script if one exists - "${BASE}.sh" - else - # Fall back to using .env and .template - # shellcheck disable=SC1090 - . "${BASE}.env" - echo "Do not read this input" | mo "${BASE}.template" + if [[ "${temp:$len}" == "$hardSpace" ]]; then + temp=${temp:0:$len} fi - ) | diff -U5 - "${TEST}" > "${BASE}.diff" - - statusCode=$? - - if [[ $statusCode -ne 0 ]]; then - echo "FAIL (status code $statusCode)" - FAIL=$(( FAIL + 1 )) else - echo "ok" - PASS=$(( PASS + 1 )) - rm "${BASE}.diff" + temp=${!name} fi -done -echo "" -echo "Pass: $PASS" -echo "Fail: $FAIL" -if [[ $FAIL -gt 0 ]]; then - exit 1 + local "$1" && indirect "$1" "$temp" +} + +runTest() ( + local testTemplate testExpected testActual hardSpace len testReturnCode testFail + + hardSpace=" " + . ../mo + + getValue testTemplate template + getValue testExpected expected + + testActual=$(echo -n "$testTemplate" | mo ${arguments[@]+"${arguments[@]}"} 2>&1; echo -n "$hardSpace$?") + testReturnCode=${testActual##*$hardSpace} + testActual=${testActual%$hardSpace*} + testFail=false + + if [[ "$testActual" != "$testExpected" ]]; then + echo "Failure" + echo "Expected:" + echo "$testExpected" + echo "Actual:" + echo "$testActual" + + if [[ -n "${MO_DEBUG-}" ]]; then + declare -p testExpected + declare -p testActual + fi + + testFail=true + fi + + if [[ "$testReturnCode" != "$returnCode" ]]; then + echo "Expected return code $returnCode, but got $testReturnCode" + testFail=true + fi + + if [[ "$testFail" == "true" ]]; then + return 1 + fi + + return 0 +) + +runTestFile() ( + local file=$1 + + echo "Test: $file" + "$file" +) + +runTests() ( + PASS=0 + FAIL=0 + + if [[ $# -gt 0 ]]; then + for TEST in "$@"; do + runTestFile "$TEST" && PASS=$((PASS + 1)) || FAIL=$((FAIL + 1)) + done + else + cd "${0%/*}" + for TEST in tests/*; do + if [[ -f "$TEST" ]]; then + runTestFile "$TEST" && PASS=$((PASS + 1)) || FAIL=$((FAIL + 1)) + fi + done + fi + + echo "" + echo "Pass: $PASS" + echo "Fail: $FAIL" + + if [[ $FAIL -gt 0 ]]; then + exit 1 + fi +) + +# Clear test related variables +template="Template not defined" +expected="Expected not defined" +returnCode=0 +arguments=() + +# If sourced, load functions. +# If executed, perform the actions as expected. +if [[ "$0" == "${BASH_SOURCE[0]}" ]] || [[ -z "${BASH_SOURCE[0]}" ]]; then + runTests ${@+"${@}"} fi diff --git a/tests/ampersand b/tests/ampersand new file mode 100755 index 0000000..735babf --- /dev/null +++ b/tests/ampersand @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +thing="Works" +template="{{&thing}}" +expected="Works" + +runTest diff --git a/tests/array b/tests/array new file mode 100755 index 0000000..9e9340d --- /dev/null +++ b/tests/array @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +repo=( "resque" "hub" "rip" ) +template() { + cat <{{@key}} - {{.}} +{{/repo}} +EOF +} +expected() { + cat <0 - resque + 1 - hub + 2 - rip +EOF +} + +runTest diff --git a/tests/array.env b/tests/array.env deleted file mode 100644 index e8a0a4e..0000000 --- a/tests/array.env +++ /dev/null @@ -1 +0,0 @@ -repo=( "resque" "hub" "rip" ) diff --git a/tests/array.expected b/tests/array.expected deleted file mode 100644 index 94d9b8e..0000000 --- a/tests/array.expected +++ /dev/null @@ -1,3 +0,0 @@ - 0 - resque - 1 - hub - 2 - rip diff --git a/tests/array.template b/tests/array.template deleted file mode 100644 index 771906f..0000000 --- a/tests/array.template +++ /dev/null @@ -1,3 +0,0 @@ -{{#repo}} - {{@key}} - {{.}} -{{/repo}} diff --git a/tests/assoc-array b/tests/assoc-array new file mode 100755 index 0000000..dffa3b1 --- /dev/null +++ b/tests/assoc-array @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +declare -A repo +repo[resque]="Resque" +repo[hub]="Hub" +repo[rip]="Rip" +template() { + cat <{{@key}} - {{.}} +{{/repo}} +EOF +} +expected() { + cat <hub - Hub + rip - Rip + resque - Resque +EOF +} + +runTest diff --git a/tests/assoc-array.env b/tests/assoc-array.env deleted file mode 100644 index 934cade..0000000 --- a/tests/assoc-array.env +++ /dev/null @@ -1,4 +0,0 @@ -declare -A repo -repo[resque]="Resque" -repo[hub]="Hub" -repo[rip]="Rip" diff --git a/tests/assoc-array.expected b/tests/assoc-array.expected deleted file mode 100644 index 6ef4ced..0000000 --- a/tests/assoc-array.expected +++ /dev/null @@ -1,3 +0,0 @@ - hub - Hub - rip - Rip - resque - Resque diff --git a/tests/assoc-array.template b/tests/assoc-array.template deleted file mode 100644 index 771906f..0000000 --- a/tests/assoc-array.template +++ /dev/null @@ -1,3 +0,0 @@ -{{#repo}} - {{@key}} - {{.}} -{{/repo}} diff --git a/tests/comment b/tests/comment new file mode 100755 index 0000000..73ccfb9 --- /dev/null +++ b/tests/comment @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +template="Wor{{!comment}}ks" +expected="Works" + +runTest diff --git a/tests/comment-newline b/tests/comment-newline new file mode 100755 index 0000000..a308529 --- /dev/null +++ b/tests/comment-newline @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +template() { + cat <Today{{! ignore me +and this can +run through multiple +lines}}. +EOF +} +expected() { + cat <Today. +EOF +} + +runTest diff --git a/tests/comment-newline.env b/tests/comment-newline.env deleted file mode 100644 index e69de29..0000000 diff --git a/tests/comment-newline.expected b/tests/comment-newline.expected deleted file mode 100644 index e7c3be5..0000000 --- a/tests/comment-newline.expected +++ /dev/null @@ -1 +0,0 @@ -

Today.

diff --git a/tests/comment-newline.template b/tests/comment-newline.template deleted file mode 100644 index 670ab1d..0000000 --- a/tests/comment-newline.template +++ /dev/null @@ -1,4 +0,0 @@ -

Today{{! ignore me -and this can -run through multiple -lines}}.

diff --git a/tests/comment-with-spaces b/tests/comment-with-spaces new file mode 100755 index 0000000..3e226d3 --- /dev/null +++ b/tests/comment-with-spaces @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +template="Wor{{! comment }}ks" +expected="Works" + +runTest diff --git a/tests/comment.env b/tests/comment.env deleted file mode 100644 index e69de29..0000000 diff --git a/tests/comment.expected b/tests/comment.expected deleted file mode 100644 index e7c3be5..0000000 --- a/tests/comment.expected +++ /dev/null @@ -1 +0,0 @@ -

Today.

diff --git a/tests/comment.template b/tests/comment.template deleted file mode 100644 index 9f7a242..0000000 --- a/tests/comment.template +++ /dev/null @@ -1 +0,0 @@ -

Today{{! ignore me }}.

diff --git a/tests/concatenated-variables b/tests/concatenated-variables new file mode 100755 index 0000000..0b5772e --- /dev/null +++ b/tests/concatenated-variables @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +thing="Wor" +thing2="ks" +template="{{thing thing2}}" +expected="Works" + +runTest diff --git a/tests/delimiters b/tests/delimiters new file mode 100755 index 0000000..5a80429 --- /dev/null +++ b/tests/delimiters @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +thing="Works" +template="{{=| |=}}|thing|" +expected="Works" + +runTest diff --git a/tests/double-hyphen b/tests/double-hyphen new file mode 100755 index 0000000..b76ea4e --- /dev/null +++ b/tests/double-hyphen @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +arguments=(-- --help) +template="" +expected="cat: --help: No such file or directory"$'\n' + +runTest diff --git a/tests/double-hyphen.expected b/tests/double-hyphen.expected deleted file mode 100644 index 84672c3..0000000 --- a/tests/double-hyphen.expected +++ /dev/null @@ -1 +0,0 @@ -cat: --help: No such file or directory diff --git a/tests/double-hyphen.sh b/tests/double-hyphen.sh deleted file mode 100755 index 5b436be..0000000 --- a/tests/double-hyphen.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -# This should display a message indicating that the file --help -# could not be found. It should not display a help messsage. -cd "${0%/*}" || exit 1 -../mo -- --help 2>&1 diff --git a/tests/double-quote b/tests/double-quote new file mode 100755 index 0000000..d5b9ec0 --- /dev/null +++ b/tests/double-quote @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +template='{{"Works"}}' +expected="Works" + +runTest diff --git a/tests/fail-not-set b/tests/fail-not-set new file mode 100755 index 0000000..bfab1da --- /dev/null +++ b/tests/fail-not-set @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +unset __NO_SUCH_VAR +POPULATED="words" +EMPTY="" +arguments=(--fail-not-set) +returnCode=1 + +template() { + cat <&1 - -if [[ $? -ne 1 ]]; then - echo "Did not return 1" -fi diff --git a/tests/fail-not-set-file.template b/tests/fail-not-set-file.template deleted file mode 100644 index d249abe..0000000 --- a/tests/fail-not-set-file.template +++ /dev/null @@ -1,3 +0,0 @@ -Populated: {{POPULATED}}; -Empty: {{EMPTY}}; -Unset: {{__NO_SUCH_VAR}}; diff --git a/tests/fail-not-set.expected b/tests/fail-not-set.expected deleted file mode 100644 index 6713890..0000000 --- a/tests/fail-not-set.expected +++ /dev/null @@ -1,3 +0,0 @@ -Populated: words; -Empty: ; -Unset: Env variable not set: __NO_SUCH_VAR diff --git a/tests/fail-not-set.sh b/tests/fail-not-set.sh deleted file mode 100755 index 5879ba2..0000000 --- a/tests/fail-not-set.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -cd "${0%/*}" || exit 1 -unset __NO_SUCH_VAR -POPULATED="words" EMPTY="" ../mo --fail-not-set 2>&1 <&1 <$(cat)" +} +template() { + cat < Willy is awesome.... this is the last line. +EOF +} + +runTest diff --git a/tests/function-args b/tests/function-args new file mode 100755 index 0000000..d4d9240 --- /dev/null +++ b/tests/function-args @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +name=Willy +MO_ALLOW_FUNCTION_ARGUMENTS=true + +pipeTo() { + cat | "$1" +} + +testArgs() { + printf "%d" "$#" + + # Display all arguments + printf " %q" ${@+"$@"} +} +template() { + cat <$(cat)" -} diff --git a/tests/function.expected b/tests/function.expected deleted file mode 100644 index d9120c0..0000000 --- a/tests/function.expected +++ /dev/null @@ -1 +0,0 @@ - Willy is awesome.... this is the last line. diff --git a/tests/function.template b/tests/function.template deleted file mode 100644 index 91a2d9e..0000000 --- a/tests/function.template +++ /dev/null @@ -1,4 +0,0 @@ -{{#wrapped}} - {{name}} is awesome. -{{/wrapped}} -... this is the last line. diff --git a/tests/globals-in-loop b/tests/globals-in-loop new file mode 100755 index 0000000..e4f223b --- /dev/null +++ b/tests/globals-in-loop @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +STR=abc +DATA=(111 222) +template() { + cat < fixtures/indented-partials.partial}} + + {{> fixtures/indented-partials.partial}} + +Without spacing + {{> fixtures/indented-partials.partial}} + {{> fixtures/indented-partials.partial}} + +With text + {{> fixtures/indented-partials.partial}} + text + {{> fixtures/indented-partials.partial}} + +In a conditional +{{#thisIsTrue}} + {{> fixtures/indented-partials.partial}} +{{/thisIsTrue}} +EOF +} +expected() { + cat < indented-partials.partial}} - - {{> indented-partials.partial}} - -Without spacing - {{> indented-partials.partial}} - {{> indented-partials.partial}} - -With text - {{> indented-partials.partial}} - text - {{> indented-partials.partial}} - -In a conditional -{{#thisIsTrue}} - {{> indented-partials.partial}} -{{/thisIsTrue}} diff --git a/tests/invalid-option b/tests/invalid-option new file mode 100755 index 0000000..f32c36a --- /dev/null +++ b/tests/invalid-option @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +person="" +template="" +arguments=(--something) +expected() { + cat <&1 diff --git a/tests/inverted b/tests/inverted new file mode 100755 index 0000000..b9ad9ed --- /dev/null +++ b/tests/inverted @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +repo=() +template() { + cat <{{.}} +{{/repo}} +{{^repo}} + No repos :( +{{/repo}} +EOF +} +expected() { + cat <{{.}} -{{/repo}} -{{^repo}} - No repos :( -{{/repo}} diff --git a/tests/miss b/tests/miss new file mode 100755 index 0000000..3010c31 --- /dev/null +++ b/tests/miss @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +name="Chris" +company="GitHub" +template() { + cat <GitHub. +* .GitHub. +EOF +} + +runTest diff --git a/tests/miss.env b/tests/miss.env deleted file mode 100644 index dfba6af..0000000 --- a/tests/miss.env +++ /dev/null @@ -1,2 +0,0 @@ -name="Chris" -company="GitHub" diff --git a/tests/miss.expected b/tests/miss.expected deleted file mode 100644 index 271627a..0000000 --- a/tests/miss.expected +++ /dev/null @@ -1,4 +0,0 @@ -* .Chris. -* .. -* .GitHub. -* .GitHub. diff --git a/tests/miss.template b/tests/miss.template deleted file mode 100644 index beaafee..0000000 --- a/tests/miss.template +++ /dev/null @@ -1,4 +0,0 @@ -* .{{name}}. -* .{{age}}. -* .{{company}}. -* .{{{company}}}. diff --git a/tests/multi-line-partial b/tests/multi-line-partial new file mode 100755 index 0000000..95772b0 --- /dev/null +++ b/tests/multi-line-partial @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +multilineData=$'line 1\nline 2' +template() { + cat < fixtures/multi-line-partial.partial}} + +Indented: + + {{> fixtures/multi-line-partial.partial}} +EOF +} +expected() { + cat < multi-line-partial.partial}} - -Indented: - - {{> multi-line-partial.partial}} diff --git a/tests/mush b/tests/mush new file mode 100755 index 0000000..349da33 --- /dev/null +++ b/tests/mush @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +USER=jwerle +GENDER=male +THING=apple +COLOR=red +PERSON=tobi +ADJECTIVE=cool +template() { + cat <Names +{{#names}} + {{> partial.partial}} +{{/names}} +EOF +} +expected() { + cat <Names + Tyler + +EOF +} + +runTest diff --git a/tests/partial-missing b/tests/partial-missing new file mode 100755 index 0000000..a20871b --- /dev/null +++ b/tests/partial-missing @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +returnCode=1 +person="" +template() { + cat < fixtures/partial-missing.partial}} +EOF +} +expected() { + cat <&1 - -if [[ $? -ne 1 ]]; then - echo "Did not return 1" -fi diff --git a/tests/partial-missing.template b/tests/partial-missing.template deleted file mode 100644 index bbd3a9e..0000000 --- a/tests/partial-missing.template +++ /dev/null @@ -1 +0,0 @@ -Won't be there: {{> partial-missing.partial}} diff --git a/tests/partial.env b/tests/partial.env deleted file mode 100644 index 6ada993..0000000 --- a/tests/partial.env +++ /dev/null @@ -1 +0,0 @@ -names=( "Tyler" ) diff --git a/tests/partial.expected b/tests/partial.expected deleted file mode 100644 index 8d66fc0..0000000 --- a/tests/partial.expected +++ /dev/null @@ -1,3 +0,0 @@ -

Names

- Tyler - diff --git a/tests/partial.template b/tests/partial.template deleted file mode 100644 index af24a02..0000000 --- a/tests/partial.template +++ /dev/null @@ -1,4 +0,0 @@ -

Names

-{{#names}} - {{> partial.partial}} -{{/names}} diff --git a/tests/single-quote b/tests/single-quote new file mode 100755 index 0000000..1d4a013 --- /dev/null +++ b/tests/single-quote @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +template="{{'Works'}}" +expected="Works" + +runTest diff --git a/tests/single-variable-replacement b/tests/single-variable-replacement new file mode 100755 index 0000000..edeca0e --- /dev/null +++ b/tests/single-variable-replacement @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +thing="Works" +template="{{thing}}" +expected="Works" + +runTest diff --git a/tests/source b/tests/source new file mode 100755 index 0000000..2db9d8e --- /dev/null +++ b/tests/source @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +arguments=(--source=fixtures/source.vars) +template() { + cat <&1 - -if [[ $? -ne 1 ]]; then - echo "Did not return 1" -fi diff --git a/tests/source-multiple b/tests/source-multiple new file mode 100755 index 0000000..c6670f5 --- /dev/null +++ b/tests/source-multiple @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +cd "${0%/*}" || exit 1 +. ../run-tests + +arguments=(--source=fixtures/source-multiple-1.vars --source=fixtures/source-multiple-2.vars) +template() { + cat <&1 - -if [[ $? -ne 1 ]]; then - echo "Did not return 1" -fi diff --git a/tests/source.expected b/tests/source.expected deleted file mode 100644 index a3050ef..0000000 --- a/tests/source.expected +++ /dev/null @@ -1,5 +0,0 @@ -value -* 1 -* 2 -* 3 -AAA BBB diff --git a/tests/source.sh b/tests/source.sh deleted file mode 100755 index 7b35d4d..0000000 --- a/tests/source.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -cd "${0%/*}" || exit 1 -cat <