Initial commit

This commit is contained in:
Tyler Akins 2015-01-23 17:43:08 +00:00
commit 1151ec0a9e
40 changed files with 575 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.swp
tests/*.diff

7
LICENSE.md Normal file
View File

@ -0,0 +1,7 @@
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Except as contained in this notice, the name(s) of the above copyright holders shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Software without prior written authorization.
The end-user documentation included with the redistribution, if any, must include the following acknowledgment: "This product includes software developed by contributors", in the same place and form as other third-party acknowledgments. Alternately, this acknowledgment may appear in the software itself, in the same form and location as other such third-party acknowledgments.

55
README.md Normal file
View File

@ -0,0 +1,55 @@
Mo - Mustache Templates in Bash
===============================
[Mustache] templates are simple, logic-less templates. Because of their simplicity, they are able to be ported to many languages. The syntax is quite simple.
Hello, {{NAME}}.
I hope your {{TIME_PERIOD}} was fun.
Let's try using this with some data in bash. Save those lines to `trip.txt` and run a command like this:
NAME=Tyler TIME_PERIOD=weekend ./mo weekend-trip.txt
Your result?
Hello, Tyler.
I hope your weekend was fun.
This bash version supports conditionals, functions (both as filters and as values), as well as indexed arrays (for iteration). You are able to leverage these additional features by adding more information into the environment. It is easiest to do this when you source `mo`. See the [demo scripts](demo/) for further examples.
Requirements
------------
* Bash 3.x (the aim is to make it work on Macs)
* The "coreutils" package (`basename` and `cat`)
* ... that's it. Why? Because bash **can**!
Concessions
-----------
I admit that implementing everything in bash just doesn't make a lot of sense. For example, the following things just don't work.
* Bash does not support nested structures like fancy objects. The best you can do are arrays. I'm not able to add super complex structures to bash - it's just a shell after all!
* There's no "top level" object that `{.}` refers to in `mo`. In other languages you can say the data for the template is a string. That's ok because using `{.}` when processing a top level scope is rare. Using `{.}` works great when iterating over an array.
* HTML encoding is not built into `mo`. The `{{{...}}}` and `{{...}}` tags both work. `echo '{{TEST}}' | TEST='<b>' mo` will give you "`<b>`" instead of "`&gt;b&lt;`".
* You must make sure the data is in the environment when `mo` runs. The easiest way to do that is to source `mo` in your shell script after setting up lots of other environment variables / functions.
* Associative arrays are not addressable via their index. You can't use `{{VARIABLE_NAME.INDEX_NAME}}` and expect it to work. Associative arrays aren't supported in Bash 3.
Developing
----------
Check out the code and hack away. Please add tests to show off bugs before fixing them. New functionality should also be covered by a test.
License
-------
This program is licensed under an MIT license with an additional non-advertising clause. See [LICENSE.md](LICENSE.md) for the full text.
[Mustache]: https://mustache.github.io/

23
demo/important-file Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
date-string() { date; }
wrapper() { echo -n "*** $1 ***"; }
IP=127.0.0.1
ALLOWED_HOSTS=( 192.168.0.1 192.168.0.2 192.168.0.3 )
cat <<EOF | . mo
# {{#wrapper}}OH SO IMPORTANT{{/wrapper}}
# This file automatically generated at {{date-string}}
home_ip={{IP}}
# ALLOWED HOSTS
{{#ALLOWED_HOSTS}}allowed_host={{.}}
{{/ALLOWED_HOSTS}}{{^ALLOWED_HOSTS}}# No allowed hosts
{{/ALLOWED_HOSTS}}
# DENIED HOSTS
{{#DENIED_HOSTS}}denied_host={{.}}
{{/DENIED_HOSTS}}{{^DENIED_HOSTS}}# No denied hosts
{{/DENIED_HOSTS}}
EOF

358
mo Executable file
View File

@ -0,0 +1,358 @@
#!/bin/bash
# Return 0 if the passed name is a function. Function names are captured
# at the start of the program and are stored in the $MUSTACHE_FUNCTIONS array.
#
# 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 NAME
for NAME in ${MUSTACHE_FUNCTIONS[@]}; do
if [[ "$NAME" == "$1" ]]; then
return 0
fi
done
return 1
}
# Process a chunk of content some number of times.
#
# Parameters:
# $1: Destination variable name for the modified content
# $2: Content to parse and reparse and reparse
# $3: Ending tag for the parser
# $4-*: Values to insert into the parsed content
mustache-loop() {
local CONTENT DEST_NAME END_TAG MODIFIED_CONTENT
DEST_NAME="$1"
CONTENT="$2"
END_TAG="$3"
shift 3
# This MUST loop at least once or assignment back to ${!DEST_NAME}
# will not work.
while [[ ${#@} -gt 0 ]]; do
mustache-parse MODIFIED_CONTENT "$CONTENT" "$END_TAG" "$1"
shift
done
local "$DEST_NAME" && mustache-indirect "$DEST_NAME" "$MODIFIED_CONTENT"
}
# Parse a block of text
#
# Parameters:
# $1: Where to store content left after parsing
# $2: Block of text to change
# $3: Stop at this closing tag (eg "/NAME")
# $4: Current value (what {{.}} will mean)
mustache-parse() {
# Keep naming variables MUSTACHE_* here to not overwrite needed variables
# used in the string replacements
local MUSTACHE_CONTENT MUSTACHE_CURRENT MUSTACHE_END_TAG MUSTACHE_TAG
MUSTACHE_END_TAG="$3"
MUSTACHE_CURRENT="$4"
# Find open tags
mustache-split MUSTACHE_CONTENT "$2" '{{' '}}'
while [[ ${#MUSTACHE_CONTENT[@]} -gt 1 ]]; do
echo -n "${MUSTACHE_CONTENT[0]}"
mustache-trim MUSTACHE_TAG "${MUSTACHE_CONTENT[1]}"
MUSTACHE_CONTENT="${MUSTACHE_CONTENT[2]}"
case "$MUSTACHE_TAG" in
'#'*)
# Loop, if/then, or pass content through function
# Sets context
mustache-trim MUSTACHE_TAG "${MUSTACHE_TAG:1}"
if mustache-test "$MUSTACHE_TAG"; then
# Show / loop / pass through function
if mustache-is-function "$MUSTACHE_TAG"; then
# This is slower - need to parse twice to avoid
# subshells. First, pass content to function but
# the updated MUSTACHE_CONTENT is lost due to subshell.
$MUSTACHE_TAG "$(mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG")"
# Secondly, update MUSTACHE_CONTENT but do not output.
mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" > /dev/null 2>&1
elif mustache-is-array "$MUSTACHE_TAG"; then
eval 'mustache-loop MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" "${'"$MUSTACHE_TAG"'[@]}"'
else
mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" "$(mustache-show "$MUSTACHE_TAG")"
fi
else
# Do not show
mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" > /dev/null
fi
;;
'>'*)
# Load partial - get name of file relative to cwd
mustache-trim MUSTACHE_TAG "${MUSTACHE_TAG:1}"
# Execute in subshell to preserve current cwd
(
cd "$(dirname "$MUSTACHE_TAG")"
MUSTACHE_TAG=$(basename "$MUSTACHE_TAG")
MUSTACHE_TAG=$(cat "$MUSTACHE_TAG" 2>/dev/null)
mustache-parse MUSTACHE_TAG "$MUSTACHE_TAG" "" "$MUSTACHE_CURRENT"
echo -n $MUSTACHE_TAG
)
;;
'/'*)
# Closing tag - If we hit MUSTACHE_END_TAG, we're done.
mustache-trim MUSTACHE_TAG "${MUSTACHE_TAG:1}"
if [[ "$MUSTACHE_TAG" == "$MUSTACHE_END_TAG" ]]; then
# Tag hit - done
local "$1" && mustache-indirect "$1" "$MUSTACHE_CONTENT"
return 0
fi
# If the tag does not match, we ignore this tag
;;
'^'*)
# Display section if named thing does not exist
mustache-trim MUSTACHE_TAG "${MUSTACHE_TAG:1}"
if mustache-test "$MUSTACHE_TAG"; then
# Do not show
mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" > /dev/null 2>&1
else
# Show
mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG"
fi
;;
'!'*)
# Comment - ignore the tag entirely
;;
.)
# Current content (environment variable or function)
echo -n "$MUSTACHE_CURRENT"
;;
'{'*)
# Unescaped - split on }}} not }}
MUSTACHE_CONTENT="${MUSTACHE_TAG:1}"'}}'"$MUSTACHE_CONTENT"
mustache-split MUSTACHE_CONTENT "$MUSTACHE_CONTENT" '}}}'
mustache-trim MUSTACHE_TAG "${MUSTACHE_CONTENT[0]}"
MUSTACHE_CONTENT="${MUSTACHE_CONTENT[1]}"
# Now show the value
mustache-show "$MUSTACHE_TAG"
;;
*)
# Normal environment variable or function call
mustache-show "$MUSTACHE_TAG"
;;
esac
mustache-split MUSTACHE_CONTENT "$MUSTACHE_CONTENT" '{{' '}}'
done
echo -n "${MUSTACHE_CONTENT[0]}"
local "$1" && mustache-indirect "$1" ""
}
# Show an environment variable or the output of a function.
#
# Parameters:
# $1: Name of environment variable or function
mustache-show() {
if mustache-is-function "$1"; then
$1
else
echo -n "${!1}"
fi
}
# 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
#
# 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
}
# 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 1
}
# Trim leading and trailing whitespace from a string
#
# Parameters:
# $1: Name of variable to store trimmed string
# $2: The string
mustache-trim() {
local CR CURRENT MODIFIED NEEDLE NL TAB SPACE VAR
CR="$'\r'"
NL="$'\n'"
TAB="$'\t'"
SPACE=" "
CURRENT="$2"
LAST=""
while [[ "$CURRENT" != "$LAST" ]]; do
LAST="$CURRENT"
for VAR in CR NL TAB SPACE; do
NEEDLE="${!VAR}"
CURRENT="${CURRENT/#$NEEDLE}"
CURRENT="${CURRENT/%$NEEDLE}"
done
done
local "$1" && mustache-indirect "$1" "$CURRENT"
}
# Split a larger string into an array
#
# Parameters:
# $1: Destination variable
# $2: String to split
# $3: Starting delimeter
# $4: Ending delimeter (optional)
mustache-split() {
local POS RESULT
RESULT=( "$2" )
mustache-find-string POS "${RESULT[0]}" "$3"
if [[ $POS -ne -1 ]]; then
# The first delimeter 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 delimeter was found
RESULT[2]="${RESULT[1]:$POS + ${#4}}"
RESULT[1]="${RESULT[1]:0:$POS}"
fi
fi
fi
local "$1" && mustache-indirect-array "$1" "${RESULT[@]}"
}
# 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
}
# 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}\"\)
}
# 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 ${1+"$@"}; do
CONTENT="$CONTENT"'{{>'"$FILENAME"'}}'
done
else
# Workaround to avoid newlines being gobbled by the subshell
CONTENT="$(cat -; echo .)"
CONTENT=${CONTENT:0: -1}
fi
local "$TARGET" && mustache-indirect "$TARGET" "$CONTENT"
}
# Save the list of functions as an array
MUSTACHE_FUNCTIONS=$(declare -F)
MUSTACHE_FUNCTIONS=( ${MUSTACHE_FUNCTIONS//declare -f /} )
mustache-get-content MUSTACHE_CONTENT ${1+"$@"}
mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT"

30
run-tests Executable file
View File

@ -0,0 +1,30 @@
#!/bin/bash
cd "$(dirname $0)"
PASS=0
FAIL=0
for TEST in tests/*.expected; do
BASE="${TEST%.expected}"
echo -n "$BASE ... "
(
. "${BASE}.env"
. ./mo "${BASE}.template"
) | diff -wU5 - "${TEST}" > "${BASE}.diff"
if [[ $? -ne 0 ]]; then
echo "FAIL"
FAIL=$(( $FAIL + 1 ))
else
echo "ok"
PASS=$(( $PASS + 1 ))
rm "${BASE}.diff"
fi
done
echo ""
echo "Pass: $PASS"
echo "Fail: $FAIL"
[[ $FAIL -gt 0 ]] && exit 1

1
tests/array.env Normal file
View File

@ -0,0 +1 @@
repo=( "resque" "hub" "rip" )

6
tests/array.expected Normal file
View File

@ -0,0 +1,6 @@
<b>resque</b>
<b>hub</b>
<b>rip</b>

3
tests/array.template Normal file
View File

@ -0,0 +1,3 @@
{{#repo}}
<b>{{.}}</b>
{{/repo}}

View File

View File

@ -0,0 +1 @@
<h1>Today.</h1>

View File

@ -0,0 +1,4 @@
<h1>Today{{! ignore me
and this can
run through multiple
lines}}.</h1>

0
tests/comment.env Normal file
View File

1
tests/comment.expected Normal file
View File

@ -0,0 +1 @@
<h1>Today.</h1>

1
tests/comment.template Normal file
View File

@ -0,0 +1 @@
<h1>Today{{! ignore me }}.</h1>

1
tests/false-list.env Normal file
View File

@ -0,0 +1 @@
person=""

View File

@ -0,0 +1 @@
Shown.

View File

@ -0,0 +1,4 @@
Shown.
{{#person}}
Never shown!
{{/person}}

4
tests/function.env Normal file
View File

@ -0,0 +1,4 @@
name=Willy
wrapped() {
echo "<b>$1</b>";
}

2
tests/function.expected Normal file
View File

@ -0,0 +1,2 @@
<b>
Willy is awesome.</b>

3
tests/function.template Normal file
View File

@ -0,0 +1,3 @@
{{#wrapped}}
{{name}} is awesome.
{{/wrapped}}

1
tests/inverted.env Normal file
View File

@ -0,0 +1 @@
repo=()

3
tests/inverted.expected Normal file
View File

@ -0,0 +1,3 @@
No repos :(

6
tests/inverted.template Normal file
View File

@ -0,0 +1,6 @@
{{#repo}}
<b>{{.}}</b>
{{/repo}}
{{^repo}}
No repos :(
{{/repo}}

2
tests/miss.env Normal file
View File

@ -0,0 +1,2 @@
name="Chris"
company="<b>GitHub</b>"

4
tests/miss.expected Normal file
View File

@ -0,0 +1,4 @@
* Chris
*
* <b>GitHub</b>
* <b>GitHub</b>

4
tests/miss.template Normal file
View File

@ -0,0 +1,4 @@
* {{name}}
* {{age}}
* {{company}}
* {{{company}}}

6
tests/mush.env Normal file
View File

@ -0,0 +1,6 @@
USER=jwerle
GENDER=male
THING=apple
COLOR=red
PERSON=tobi
ADJECTIVE=cool

7
tests/mush.expected Normal file
View File

@ -0,0 +1,7 @@
jwerle is male
apple is red
tobi is cool
jwerle is friends with tobi

7
tests/mush.template Normal file
View File

@ -0,0 +1,7 @@
{{! this is a comment }}
{{USER}} is {{GENDER}}
{{THING}} is {{COLOR}}
{{PERSON}} is {{ADJECTIVE}}
{{USER}} is friends with {{PERSON}}
{{var}} {{value}}

1
tests/non-false.env Normal file
View File

@ -0,0 +1 @@
person=Jon

2
tests/non-false.expected Normal file
View File

@ -0,0 +1,2 @@
Hi Jon!

3
tests/non-false.template Normal file
View File

@ -0,0 +1,3 @@
{{#person}}
Hi {{.}}!
{{/person}}

1
tests/partial.env Normal file
View File

@ -0,0 +1 @@
names=( "Tyler" )

3
tests/partial.expected Normal file
View File

@ -0,0 +1,3 @@
<h2>Names</h2>
<strong>Tyler</strong>

1
tests/partial.partial Normal file
View File

@ -0,0 +1 @@
<strong>{{.}}</strong>

4
tests/partial.template Normal file
View File

@ -0,0 +1,4 @@
<h2>Names</h2>
{{#names}}
{{> partial.partial}}
{{/names}}

4
tests/typical.env Normal file
View File

@ -0,0 +1,4 @@
NAME="Chris"
VALUE=10000
TAXED_VALUE=6000
IN_CA=true

4
tests/typical.expected Normal file
View File

@ -0,0 +1,4 @@
Hello Chris
You have just won 10000 dollars!
Well, 6000 dollars, after taxes.

5
tests/typical.template Normal file
View File

@ -0,0 +1,5 @@
Hello {{NAME}}
You have just won {{VALUE}} dollars!
{{#IN_CA}}
Well, {{TAXED_VALUE}} dollars, after taxes.
{{/IN_CA}