mo/mo
Tyler Akins 851e619fbd Renaming all functions
This makes function names POSIX compliant.  Not a big deal with bash,
but very important if you want to use tools like ctags or tomdoc.sh.
2015-10-02 09:46:24 -05:00

767 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
#
#/ Mo is a mustache template rendering software written in bash. It inserts
#/ environment variables into templates.
#/
#/ Simply put, mo will change {{VARIABLE}} into the value of that
#/ environment variable. You can use {{#VARIABLE}}content{{/VARIABLE}} to
#/ conditionally display content or iterate over the values of an array.
#/
#/ 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
moFindEndTag() {
local CONTENT SCANNED
# 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]}"
CONTENT=${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 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" && moIndirectArray "$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
moSplit CONTENT "$CONTENT" '{{' '}}'
done
# Did not find our closing tag
SCANNED="$SCANNED${CONTENT[0]}"
local "$1" && moIndirectArray "$1" "${SCANNED}" "" ""
}
# Find the first index of a substring
#
# Parameters:
# $1: Destination variable
# $2: Haystack
# $3: Needle
moFindString() {
local POS STRING
STRING=${2%%$3*}
[[ "$STRING" == "$2" ]] && POS=-1 || POS=${#STRING}
local "$1" && moIndirect "$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
moFullTagName() {
if [[ -z "$2" ]] || [[ "$2" == *.* ]]; then
local "$1" && moIndirect "$1" "$3"
else
local "$1" && moIndirect "$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)
moGetContent() {
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
moLoadFile CONTENT /dev/stdin
fi
local "$TARGET" && moIndirect "$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
moIndentLines() {
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 moPartial
if [ -z "$2" ]; then
local "$1" && moIndirect "$1" "$CONTENT"
return 0
fi
moFindString POS_N "$CONTENT" $'\n'
moFindString 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
moTrimChars TRIMMED "$FRAGMENT" false true " " $'\t' $'\n' $'\r'
if [ ! -z "$TRIMMED" ]; then
FRAGMENT="$2$FRAGMENT"
fi
RESULT="$RESULT$FRAGMENT"
moFindString POS_N "$CONTENT" $'\n'
moFindString POS_R "$CONTENT" $'\r'
done
moTrimChars TRIMMED "$CONTENT" false true " " $'\t'
if [ ! -z "$TRIMMED" ]; then
CONTENT="$2$CONTENT"
fi
RESULT="$RESULT$CONTENT"
local "$1" && moIndirect "$1" "$RESULT"
}
# Send a variable up to caller of a function
#
# Parameters:
# $1: Variable name
# $2: Value
moIndirect() {
unset -v "$1"
printf -v "$1" '%s' "$2"
}
# Send an array up to caller of a function
#
# Parameters:
# $1: Variable name
# $2-*: Array elements
moIndirectArray() {
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
moIsArray() {
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
moIsFunction() {
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:
#
# moIsStandalone 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?
moIsStandalone() {
local AFTER_TRIMMED BEFORE_TRIMMED CHAR
moTrimChars BEFORE_TRIMMED "$2" false true " " $'\t'
moTrimChars 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" && moIndirect "$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
moJoin() {
local JOINER PART RESULT TARGET
TARGET=$1
JOINER=$2
RESULT=$3
shift 3
for PART in "$@"; do
RESULT="$RESULT$JOINER$PART"
done
local "$TARGET" && moIndirect "$TARGET" "$RESULT"
}
# Read a file
#
# Parameters:
# $1: Variable name to receive the file's content
# $2: Filename to load
moLoadFile() {
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" && moIndirect "$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
moLoop() {
local CONTENT CONTEXT CONTEXT_BASE IGNORE
CONTENT=$1
CONTEXT_BASE=$2
shift 2
while [[ "${#@}" -gt 0 ]]; do
moFullTagName CONTEXT "$CONTEXT_BASE" "$1"
moParse "$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
moParse() {
# 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
moSplit MUSTACHE_CONTENT "$1" '{{' '}}'
while [[ "${#MUSTACHE_CONTENT[@]}" -gt 1 ]]; do
moTrimWhitespace MUSTACHE_TAG "${MUSTACHE_CONTENT[1]}"
case $MUSTACHE_TAG in
'#'*)
# Loop, if/then, or pass content through function
# Sets context
moStandaloneAllowed MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING
moTrimWhitespace MUSTACHE_TAG "${MUSTACHE_TAG:1}"
moFindEndTag MUSTACHE_BLOCK "$MUSTACHE_CONTENT" "$MUSTACHE_TAG"
moFullTagName MUSTACHE_TAG "$MUSTACHE_CURRENT" "$MUSTACHE_TAG"
if moTest "$MUSTACHE_TAG"; then
# Show / loop / pass through function
if moIsFunction "$MUSTACHE_TAG"; then
# TODO: Consider piping the output to
# moGetContent so the lambda does not
# execute in a subshell?
MUSTACHE_CONTENT=$($MUSTACHE_TAG "${MUSTACHE_BLOCK[0]}")
moParse "$MUSTACHE_CONTENT" "$MUSTACHE_CURRENT" false
MUSTACHE_CONTENT="${MUSTACHE_BLOCK[2]}"
elif moIsArray "$MUSTACHE_TAG"; then
eval 'moLoop "${MUSTACHE_BLOCK[0]}" "$MUSTACHE_TAG" "${!'"$MUSTACHE_TAG"'[@]}"'
else
moParse "${MUSTACHE_BLOCK[0]}" "$MUSTACHE_CURRENT" false
fi
fi
MUSTACHE_CONTENT="${MUSTACHE_BLOCK[2]}"
;;
'>'*)
# Load partial - get name of file relative to cwd
moPartial MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING "$MUSTACHE_CURRENT"
;;
'/'*)
# Closing tag - If hit in this loop, we simply ignore
# Matching tags are found in moFindEndTag
moStandaloneAllowed MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING
;;
'^'*)
# Display section if named thing does not exist
moStandaloneAllowed MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING
moTrimWhitespace MUSTACHE_TAG "${MUSTACHE_TAG:1}"
moFindEndTag MUSTACHE_BLOCK "$MUSTACHE_CONTENT" "$MUSTACHE_TAG"
moFullTagName MUSTACHE_TAG "$MUSTACHE_CURRENT" "$MUSTACHE_TAG"
if ! moTest "$MUSTACHE_TAG"; then
moParse "${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
moStandaloneAllowed MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING
;;
.)
# Current content (environment variable or function)
moStandaloneDenied MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}"
moShow "$MUSTACHE_CURRENT" "$MUSTACHE_CURRENT"
;;
'=')
# Change delimiters
# Any two non-whitespace sequences separated by whitespace.
# TODO
moStandaloneAllowed MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}" $MUSTACHE_IS_BEGINNING
;;
'{'*)
# Unescaped - split on }}} not }}
moStandaloneDenied MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}"
MUSTACHE_CONTENT="${MUSTACHE_TAG:1}"'}}'"$MUSTACHE_CONTENT"
moSplit MUSTACHE_CONTENT "$MUSTACHE_CONTENT" '}}}'
moTrimWhitespace MUSTACHE_TAG "${MUSTACHE_CONTENT[0]}"
moFullTagName MUSTACHE_TAG "$MUSTACHE_CURRENT" "$MUSTACHE_TAG"
MUSTACHE_CONTENT=${MUSTACHE_CONTENT[1]}
# Now show the value
moShow "$MUSTACHE_TAG" "$MUSTACHE_CURRENT"
;;
'&'*)
# Unescaped
moStandaloneDenied MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}"
moTrimWhitespace MUSTACHE_TAG "${MUSTACHE_TAG:1}"
moFullTagName MUSTACHE_TAG "$MUSTACHE_CURRENT" "$MUSTACHE_TAG"
moShow "$MUSTACHE_TAG" "$MUSTACHE_CURRENT"
;;
*)
# Normal environment variable or function call
moStandaloneDenied MUSTACHE_CONTENT "${MUSTACHE_CONTENT[@]}"
moFullTagName MUSTACHE_TAG "$MUSTACHE_CURRENT" "$MUSTACHE_TAG"
moShow "$MUSTACHE_TAG" "$MUSTACHE_CURRENT"
;;
esac
MUSTACHE_IS_BEGINNING=false
moSplit 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
moPartial() {
local MUSTACHE_CONTENT MUSTACHE_FILENAME MUSTACHE_INDENT MUSTACHE_LINE MUSTACHE_PARTIAL MUSTACHE_STANDALONE
if moIsStandalone 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
moTrimWhitespace 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")"
moIndentLines MUSTACHE_PARTIAL "$MUSTACHE_INDENT" "$(
moLoadFile MUSTACHE_PARTIAL "${MUSTACHE_FILENAME##*/}"
# Fix bash handling of subshells
# The extra dot is removed in moIndentLines
echo -n "${MUSTACHE_PARTIAL}."
)"
moParse "$MUSTACHE_PARTIAL" "$6" true
)
local "$1" && moIndirect "$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
moShow() {
local JOINED MUSTACHE_NAME_PARTS
if moIsFunction "$1"; then
CONTENT=$($1 "")
moParse "$CONTENT" "$2" false
return 0
fi
moSplit MUSTACHE_NAME_PARTS "$1" "."
if [[ -z "${MUSTACHE_NAME_PARTS[1]}" ]]; then
if moIsArray "$1"; then
eval moJoin 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)
moSplit() {
local POS RESULT
RESULT=( "$2" )
moFindString 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
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[@]}"
}
# 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?
moStandaloneAllowed() {
local STANDALONE_BYTES
if moIsStandalone STANDALONE_BYTES "$2" "$4" $5; then
STANDALONE_BYTES=( $STANDALONE_BYTES )
echo -n "${2:0:${STANDALONE_BYTES[0]}}"
local "$1" && moIndirect "$1" "${4:${STANDALONE_BYTES[1]}}"
else
echo -n "$2"
local "$1" && moIndirect "$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
moStandaloneDenied() {
echo -n "$2"
local "$1" && moIndirect "$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
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
# 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
moTrimChars() {
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" && moIndirect "$TARGET" "$CURRENT"
}
# Trim leading and trailing whitespace from a string
#
# Parameters:
# $1: Name of variable to store trimmed string
# $2: The string
moTrimWhitespace() {
local RESULT
moTrimChars RESULT "$2" true true $'\r' $'\n' $'\t' " "
local "$1" && moIndirect "$1" "$RESULT"
}
# Displays the usage for mo. Pulls this from the file that contained
# the `mo` function. Likely will only work when
#
# $1 - Filename that has the help message
#
# Returns nothing.
moUsage() {
grep '^#/' < "$0" | cut -c 4-
}
# Template parser
#
# $* - Filenames to parse. Can use -h or --help as the only option
# in order to show a help message.
#
# Returns nothing.
mo() (
# Execute in a subshell so IFS is reset
IFS=$' \n\t'
if [[ $# -gt 0 ]]; then
case "$1" in
-h|--h|--he|--hel|--help)
moUsage "$0"
exit 0
;;
esac
fi
moGetContent MUSTACHE_CONTENT "$@"
moParse "$MUSTACHE_CONTENT" "" true
)
if [[ "$0" == "$BASH_SOURCE" ]] || ! [[ -n "$BASH_SOURCE" ]]; then
mo "$@"
fi