#!/bin/bash # # Mo is a mustache template rendering software written in bash. It inserts # environment variables into templates. # # Learn more about mustache templates at https://mustache.github.io/ # # Mo is under a MIT style licence with an additional non-advertising clause. # See LICENSE.md for the full text. # # This is open source! Please feel free to contribute. # # https://github.com/tests-always-included/mo # Scan content until the right end tag is found. Returns 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. # # Parameters: # $1: Where to store the array # $2: Content # $3: Name of end tag # $4: If -z, do standalone tag processing before finishing mustache-find-end-tag() { local CONTENT SCANNED # Find open tags SCANNED="" mustache-split CONTENT "$2" '{{' '}}' while [[ "${#CONTENT[@]}" -gt 1 ]]; do mustache-trim-whitespace 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]}" mustache-trim-whitespace TAG "${TAG:1}" mustache-find-end-tag CONTENT "${CONTENT[2]}" "$TAG" "loop" SCANNED="${SCANNED}${CONTENT[0]}${CONTENT[1]}" CONTENT=${CONTENT[2]} ;; '/'*) # End a block - could be ours mustache-trim-whitespace TAG "${TAG:1}" SCANNED="$SCANNED${CONTENT[0]}" if [[ "$TAG" == "$3" ]]; then # Found our end tag if [[ -z "$4" ]] && mustache-is-standalone STANDALONE_BYTES "$SCANNED" "${CONTENT[2]}" true; then # This is also a standalone tag - clean up whitespace # and move those whitespace bytes to the "tag" element STANDALONE_BYTES=( $STANDALONE_BYTES ) CONTENT[1]="${SCANNED:${STANDALONE_BYTES[0]}}${CONTENT[1]}${CONTENT[2]:0:${STANDALONE_BYTES[1]}}" SCANNED="${SCANNED:0:${STANDALONE_BYTES[0]}}" CONTENT[2]="${CONTENT[2]:${STANDALONE_BYTES[1]}}" fi local "$1" && mustache-indirect-array "$1" "$SCANNED" "${CONTENT[1]}" "${CONTENT[2]}" return 0 fi SCANNED="$SCANNED${CONTENT[1]}" CONTENT=${CONTENT[2]} ;; *) # Ignore all other tags SCANNED="${SCANNED}${CONTENT[0]}${CONTENT[1]}" CONTENT=${CONTENT[2]} ;; esac mustache-split CONTENT "$CONTENT" '{{' '}}' done # Did not find our closing tag SCANNED="$SCANNED${CONTENT[0]}" local "$1" && mustache-indirect-array "$1" "${SCANNED}" "" "" } # Find the first index of a substring # # Parameters: # $1: Destination variable # $2: Haystack # $3: Needle mustache-find-string() { local POS STRING STRING=${2%%$3*} [[ "$STRING" == "$2" ]] && POS=-1 || POS=${#STRING} local "$1" && mustache-indirect "$1" $POS } # Return a dotted name based on current context and target name # # Parameters: # $1: Target variable to store results # $2: Context name # $3: Desired variable name mustache-full-tag-name() { if [[ -z "$2" ]]; then local "$1" && mustache-indirect "$1" "$3" else local "$1" && mustache-indirect "$1" "${2}.${3}" fi } # Return the content to parse. Can be a list of partials for files or # the content from stdin. # # Parameters: # $1: Variable name to assign this content back as # $2-*: File names (optional) mustache-get-content() { local CONTENT FILENAME TARGET TARGET=$1 shift if [[ "${#@}" -gt 0 ]]; then CONTENT="" for FILENAME in "$@"; do # This is so relative paths work from inside template files CONTENT="$CONTENT"'{{>'"$FILENAME"'}}' done else mustache-load-file CONTENT /dev/stdin fi local "$TARGET" && mustache-indirect "$TARGET" "$CONTENT" } # Indent a string, placing the indent at the beginning of every # line that has any content. # # Parameters: # $1: Name of destination variable to get an array of lines # $2: The indent string # $3: The string to reindent mustache-indent-lines() { local CONTENT FRAGMENT LEN POS_N POS_R RESULT TRIMMED RESULT="" LEN=$((${#3} - 1)) CONTENT="${3:0:$LEN}" # Remove newline and dot from workaround - in mustache-partial if [ -z "$2" ]; then local "$1" && mustache-indirect "$1" "$CONTENT" return 0 fi mustache-find-string POS_N "$CONTENT" $'\n' mustache-find-string POS_R "$CONTENT" $'\r' while [[ "$POS_N" -gt -1 ]] || [[ "$POS_R" -gt -1 ]]; do if [[ "$POS_N" -gt -1 ]]; then FRAGMENT="${CONTENT:0:$POS_N + 1}" CONTENT=${CONTENT:$POS_N + 1} else FRAGMENT="${CONTENT:0:$POS_R + 1}" CONTENT=${CONTENT:$POS_R + 1} fi mustache-trim-chars TRIMMED "$FRAGMENT" false true " " $'\t' $'\n' $'\r' if [ ! -z "$TRIMMED" ]; then FRAGMENT="$2$FRAGMENT" fi RESULT="$RESULT$FRAGMENT" mustache-find-string POS_N "$CONTENT" $'\n' mustache-find-string POS_R "$CONTENT" $'\r' done mustache-trim-chars TRIMMED "$CONTENT" false true " " $'\t' if [ ! -z "$TRIMMED" ]; then CONTENT="$2$CONTENT" fi RESULT="$RESULT$CONTENT" local "$1" && mustache-indirect "$1" "$RESULT" } # Send a variable up to caller of a function # # Parameters: # $1: Variable name # $2: Value mustache-indirect() { unset -v "$1" printf -v "$1" '%s' "$2" } # Send an array up to caller of a function # # Parameters: # $1: Variable name # $2-*: Array elements mustache-indirect-array() { unset -v "$1" eval $1=\(\"\${@:2}\"\) } # Determine if a given environment variable exists and if it is an array. # # Parameters: # $1: Name of environment variable # # Return code: # 0 if the name is not empty, 1 otherwise mustache-is-array() { local MUSTACHE_TEST MUSTACHE_TEST=$(declare -p "$1" 2>/dev/null) || return 1 [[ "${MUSTACHE_TEST:0:10}" == "declare -a" ]] && return 0 [[ "${MUSTACHE_TEST:0:10}" == "declare -A" ]] && return 0 return 1 } # Return 0 if the passed name is a function. # # Parameters: # $1: Name to check if it's a function # # Return code: # 0 if the name is a function, 1 otherwise mustache-is-function() { local FUNCTIONS NAME FUNCTIONS=$(declare -F) FUNCTIONS=( ${FUNCTIONS//declare -f /} ) for NAME in ${FUNCTIONS[@]}; do if [[ "$NAME" == "$1" ]]; then return 0 fi done return 1 } # Determine if the tag is a standalone tag based on whitespace before and # after the tag. # # 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: # # mustache-is-standalone RESULT "$before" "$after" false || return 0 # RESULT_ARRAY=( $RESULT ) # echo "${before:0:${RESULT_ARRAY[0]}}...${after:${RESULT_ARRAY[1]}}" # # Parameters: # $1: Variable to pass data back # $2: Content before the tag # $3: Content after the tag # $4: true/false: is this the beginning of the content? mustache-is-standalone() { local AFTER_TRIMMED BEFORE_TRIMMED CHAR mustache-trim-chars BEFORE_TRIMMED "$2" false true " " $'\t' mustache-trim-chars AFTER_TRIMMED "$3" true false " " $'\t' CHAR=$((${#BEFORE_TRIMMED} - 1)) CHAR=${BEFORE_TRIMMED:$CHAR} if [[ "$CHAR" != $'\n' ]] && [[ "$CHAR" != $'\r' ]]; then if [[ ! -z "$CHAR" ]] || ! $4; then return 1; fi fi CHAR=${AFTER_TRIMMED:0:1} if [[ "$CHAR" != $'\n' ]] && [[ "$CHAR" != $'\r' ]] && [[ ! -z "$CHAR" ]]; then return 2; fi if [[ "$CHAR" == $'\r' ]] && [[ "${AFTER_TRIMMED:1:1}" == $'\n' ]]; then CHAR="$CHAR"$'\n' fi local "$1" && mustache-indirect "$1" "$((${#BEFORE_TRIMMED})) $((${#3} + ${#CHAR} - ${#AFTER_TRIMMED}))" } # Join / implode an array # # Parameters: # $1: Variable name to receive the joined content # $2: Joiner # $3-$*: Elements to join mustache-join() { local JOINER PART RESULT TARGET TARGET=$1 JOINER=$2 RESULT=$3 shift 3 for PART in "$@"; do RESULT="$RESULT$JOINER$PART" done local "$TARGET" && mustache-indirect "$TARGET" "$RESULT" } # Read a file # # Parameters: # $1: Variable name to receive the file's content # $2: Filename to load mustache-load-file() { local CONTENT LEN # The subshell removes any trailing newlines. We forcibly add # a dot to the content to preserve all newlines. # TODO: remove cat and replace with read loop? CONTENT=$(cat $2; echo '.') LEN=$((${#CONTENT} - 1)) CONTENT=${CONTENT:0:$LEN} # Remove last dot local "$1" && mustache-indirect "$1" "$CONTENT" } # Process a chunk of content some number of times. # # Parameters: # $1: Content to parse and reparse and reparse # $2: Tag prefix (context name) # $3-*: Names to insert into the parsed content mustache-loop() { local CONTENT CONTEXT CONTEXT_BASE IGNORE CONTENT=$1 CONTEXT_BASE=$2 shift 2 while [[ "${#@}" -gt 0 ]]; do mustache-full-tag-name CONTEXT "$CONTEXT_BASE" "$1" mustache-parse "$CONTENT" "$CONTEXT" false shift done } # Parse a block of text # # Parameters: # $1: Block of text to change # $2: Current name (the variable NAME for what {{.}} means) # $3: true when no content before this, false otherwise mustache-parse() { # Keep naming variables MUSTACHE_* here to not overwrite needed variables # used in the string replacements local MUSTACHE_BLOCK MUSTACHE_CONTENT MUSTACHE_CURRENT MUSTACHE_IS_BEGINNING MUSTACHE_TAG MUSTACHE_CURRENT=$2 MUSTACHE_IS_BEGINNING=$3 # Find open tags mustache-split MUSTACHE_CONTENT "$1" '{{' '}}' while [[ "${#MUSTACHE_CONTENT[@]}" -gt 1 ]]; do mustache-trim-whitespace MUSTACHE_TAG "${MUSTACHE_CONTENT[1]}" case $MUSTACHE_TAG in '#'*) # Loop, if/then, or pass content through function # Sets context mustache-standalone-allowed MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING mustache-trim-whitespace MUSTACHE_TAG "${MUSTACHE_TAG:1}" mustache-find-end-tag MUSTACHE_BLOCK "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" mustache-full-tag-name MUSTACHE_TAG "$MUSTACHE_CURRENT" "$MUSTACHE_TAG" if mustache-test "$MUSTACHE_TAG"; then # Show / loop / pass through function if mustache-is-function "$MUSTACHE_TAG"; then # TODO: Consider piping the output to # mustache-get-content so the lambda does not # execute in a subshell? MUSTACHE_CONTENT=$($MUSTACHE_TAG "${MUSTACHE_BLOCK[0]}") mustache-parse "$MUSTACHE_CONTENT" "$MUSTACHE_CURRENT" false MUSTACHE_CONTENT="${MUSTACHE_BLOCK[2]}" elif mustache-is-array "$MUSTACHE_TAG"; then eval 'mustache-loop "${MUSTACHE_BLOCK[0]}" "$MUSTACHE_TAG" "${!'"$MUSTACHE_TAG"'[@]}"' else mustache-parse "${MUSTACHE_BLOCK[0]}" "$MUSTACHE_CURRENT" false fi fi MUSTACHE_CONTENT="${MUSTACHE_BLOCK[2]}" ;; '>'*) # Load partial - get name of file relative to cwd mustache-partial MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING "$MUSTACHE_CURRENT" ;; '/'*) # Closing tag - If hit in this loop, we simply ignore # Matching tags are found in mustache-find-end-tag mustache-standalone-allowed MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING ;; '^'*) # Display section if named thing does not exist mustache-standalone-allowed MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING mustache-trim-whitespace MUSTACHE_TAG "${MUSTACHE_TAG:1}" mustache-find-end-tag MUSTACHE_BLOCK "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" mustache-full-tag-name MUSTACHE_TAG "$MUSTACHE_CURRENT" "$MUSTACHE_TAG" if ! mustache-test "$MUSTACHE_TAG"; then mustache-parse "${MUSTACHE_BLOCK[0]}" "$MUSTACHE_CURRENT" false "$MUSTACHE_CURRENT" fi MUSTACHE_CONTENT="${MUSTACHE_BLOCK[2]}" ;; '!'*) # Comment - ignore the tag content entirely # Trim spaces/tabs before the comment mustache-standalone-allowed MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING ;; .) # Current content (environment variable or function) mustache-standalone-denied MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" mustache-show "$MUSTACHE_CURRENT" "$MUSTACHE_CURRENT" ;; '=') # Change delimiters # Any two non-whitespace sequences separated by whitespace. # TODO mustache-standalone-allowed MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING ;; '{'*) # Unescaped - split on }}} not }} mustache-standalone-denied MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" MUSTACHE_CONTENT="${MUSTACHE_TAG:1}"'}}'"$MUSTACHE_CONTENT" mustache-split MUSTACHE_CONTENT "$MUSTACHE_CONTENT" '}}}' mustache-trim-whitespace MUSTACHE_TAG "${MUSTACHE_CONTENT[0]}" mustache-full-tag-name MUSTACHE_TAG "$MUSTACHE_CURRENT" "$MUSTACHE_TAG" MUSTACHE_CONTENT=${MUSTACHE_CONTENT[1]} # Now show the value mustache-show "$MUSTACHE_TAG" "$MUSTACHE_CURRENT" ;; '&'*) # Unescaped mustache-standalone-denied MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" mustache-trim-whitespace MUSTACHE_TAG "${MUSTACHE_TAG:1}" mustache-full-tag-name MUSTACHE_TAG "$MUSTACHE_CURRENT" "$MUSTACHE_TAG" mustache-show "$MUSTACHE_TAG" "$MUSTACHE_CURRENT" ;; *) # Normal environment variable or function call mustache-standalone-denied MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" mustache-full-tag-name MUSTACHE_TAG "$MUSTACHE_CURRENT" "$MUSTACHE_TAG" mustache-show "$MUSTACHE_TAG" "$MUSTACHE_CURRENT" ;; esac MUSTACHE_IS_BEGINNING=false mustache-split MUSTACHE_CONTENT "$MUSTACHE_CONTENT" '{{' '}}' done echo -n "${MUSTACHE_CONTENT[0]}" } # Process a partial # # Indentation should be applied to the entire partial # # Prefix all variables # # Parameters: # $1: Name of destination "content" variable. # $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 mustache-partial() { local MUSTACHE_CONTENT MUSTACHE_FILENAME MUSTACHE_INDENT MUSTACHE_LINE MUSTACHE_PARTIAL MUSTACHE_STANDALONE if mustache-is-standalone MUSTACHE_STANDALONE "$2" "$4" $5; then MUSTACHE_STANDALONE=( $MUSTACHE_STANDALONE ) echo -n "${2:0:${MUSTACHE_STANDALONE[0]}}" MUSTACHE_INDENT=${2:${MUSTACHE_STANDALONE[0]}} MUSTACHE_CONTENT=${4:${MUSTACHE_STANDALONE[1]}} else MUSTACHE_INDENT="" echo -n "$2" MUSTACHE_CONTENT=$4 fi mustache-trim-whitespace MUSTACHE_FILENAME "${3:1}" # Execute in subshell to preserve current cwd and environment ( # TODO: Remove dirname and use a function instead cd "$(dirname "$MUSTACHE_FILENAME")" mustache-indent-lines MUSTACHE_PARTIAL "$MUSTACHE_INDENT" "$( mustache-load-file MUSTACHE_PARTIAL "${MUSTACHE_FILENAME##*/}" # Fix bash handling of subshells # The extra dot is removed in mustache-indent-lines echo -n "${MUSTACHE_PARTIAL}." )" mustache-parse "$MUSTACHE_PARTIAL" "$6" true ) local "$1" && mustache-indirect "$1" "$MUSTACHE_CONTENT" } # Show an environment variable or the output of a function. # # Limit/prefix any variables used # # Parameters: # $1: Name of environment variable or function # $2: Current context mustache-show() { local JOINED MUSTACHE_NAME_PARTS if mustache-is-function "$1"; then CONTENT=$($1 "") mustache-parse "$CONTENT" "$2" false return 0 fi mustache-split MUSTACHE_NAME_PARTS "$1" "." if [[ -z "${MUSTACHE_NAME_PARTS[1]}" ]]; then if mustache-is-array "$1"; then eval mustache-join JOINED "," "\${$1[@]}" echo -n "$JOINED" else echo -n "${!1}" fi else # Further subindexes are disallowed eval 'echo -n "${'"${MUSTACHE_NAME_PARTS[0]}"'['"${MUSTACHE_NAME_PARTS[1]%%.*}"']}"' fi } # Split a larger string into an array # # Parameters: # $1: Destination variable # $2: String to split # $3: Starting delimiter # $4: Ending delimiter (optional) mustache-split() { local POS RESULT RESULT=( "$2" ) mustache-find-string POS "${RESULT[0]}" "$3" if [[ "$POS" -ne -1 ]]; then # The first delimiter was found RESULT[1]=${RESULT[0]:$POS + ${#3}} RESULT[0]=${RESULT[0]:0:$POS} if [[ ! -z "$4" ]]; then mustache-find-string 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" && mustache-indirect-array "$1" "${RESULT[@]}" } # 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. # # Parameters: # $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? mustache-standalone-allowed() { local STANDALONE_BYTES if mustache-is-standalone STANDALONE_BYTES "$2" "$4" $5; then STANDALONE_BYTES=( $STANDALONE_BYTES ) echo -n "${2:0:${STANDALONE_BYTES[0]}}" local "$1" && mustache-indirect "$1" "${4:${STANDALONE_BYTES[1]}}" else echo -n "$2" local "$1" && mustache-indirect "$1" "$4" fi } # Handle the content for a tag that is never "standalone". No adjustments # are made for newlines and whitespace. # # Parameters: # $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 mustache-standalone-denied() { echo -n "$2" local "$1" && mustache-indirect "$1" "$4" } # Returns 0 (success) if the named thing is a function or if it is a non-empty # environment variable. # # Do not use unprefixed variables here if possible as this needs to check # if any name exists in the environment # # Parameters: # $1: Name of environment variable or function # $2: Current value (our context) # # Return code: # 0 if the name is not empty, 1 otherwise mustache-test() { # Test for functions mustache-is-function "$1" && return 0 if mustache-is-array "$1"; then # Arrays must have at least 1 element eval '[[ "${#'"$1"'[@]}" -gt 0 ]]' && return 0 else # Environment variables must not be empty [[ ! -z "${!1}" ]] && return 0 fi return 1 } # Trim the leading whitespace only # # Parameters: # $1: Name of destination variable # $2: The string # $3: true/false - trim front? # $4: true/false - trim end? # $5-*: Characters to trim mustache-trim-chars() { local BACK CURRENT FRONT LAST TARGET VAR TARGET=$1 CURRENT=$2 FRONT=$3 BACK=$4 LAST="" shift # Remove target shift # Remove string shift # Remove trim front flag shift # Remove trim end flag while [[ "$CURRENT" != "$LAST" ]]; do LAST=$CURRENT for VAR in "$@"; do $FRONT && CURRENT="${CURRENT/#$VAR}" $BACK && CURRENT="${CURRENT/%$VAR}" done done local "$TARGET" && mustache-indirect "$TARGET" "$CURRENT" } # Trim leading and trailing whitespace from a string # # Parameters: # $1: Name of variable to store trimmed string # $2: The string mustache-trim-whitespace() { local RESULT mustache-trim-chars RESULT "$2" true true $'\r' $'\n' $'\t' " " local "$1" && mustache-indirect "$1" "$RESULT" } mustache-get-content MUSTACHE_CONTENT "$@" mustache-parse "$MUSTACHE_CONTENT" "" true