#!/bin/sh
# vim: set ts=4:
#---help---
# USAGE:
#   esh [options] [--] <input> [<variable>...]
#   esh <-h | -V>
#
# Process and evaluate an ESH template.
#
# ARGUMENTS:
#   <input>        Path of the template file or "-" to read from STDIN.
#   <variable>     Variable(s) specified as <name>=<value> to pass into the
#                  template (the have higher priority than environment
#                  variables).
#
# OPTIONS:
#   -d             Don't evaluate template, just dump a shell script.
#   -o <file>      Output file or "-" for STDOUT. Defaults to "-".
#   -s <shell>     Command name or path of the shell to use for template
#                  evaluation. It must not contain spaces.
#                  Defaults to "/bin/sh".
#   -h             Show this help message and exit.
#   -V             Print version and exit.
#
# ENVIRONMENT:
#   ESH_AWK        Command name of path of the awk program to use.
#                  It must not contain spaces. Defaults to "awk".
#   ESH_MAX_DEPTH  Maximum include depth. Defaults to 3.
#   ESH_SHELL      Same as -s.
#
# EXIT STATUS:
#    0             Clean exit, no error has encountered.
#    1             Generic error.
#   10             Invalid usage.
#   11             ESH syntax error.
#   12             Include error: file not found.
#   13             Include error: exceeded max include depth (ESH_MAX_DEPTH).
#
# Please report bugs at <https://github.com/jirutka/esh/issues>.
#---help---
set -eu

readonly PROGNAME='esh'
readonly VERSION='0.3.2'
readonly SCRIPTPATH="$0"

AWK_CONVERTER=$(cat <<'AWK'
function fail(code, msg) {
    state = "ERROR"
    # FIXME: /dev/stderr is not portable
    printf("%s: %s\n", line_info(), msg) > "/dev/stderr"
    exit code
}
function line_info() {
    return FILENAME ? (filenames[depth] ":" linenos[depth]) : "(init)"  # (init) if inside BEGIN
}
# IMPORTANT: This is the only function that should print a newline.
function puts(str) {
    print(line_info()) > MAP_FILE
    print(str)
}
function fputs(str) {
    printf("%s", str)
}
function trim(str) {
    gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "", str)
    return str
}
function read(len,  _str) {
    if (len == "") {
        _str = buff
        buff = ""
    } else if (len > 0) {
        _str = substr(buff, 1, len)
        buff = substr(buff, len + 1, length(buff))
    }
    return _str
}
function skip(len) {
    buff = substr(buff, len + 1, length(buff))
}
function flush(len,  _str) {
    _str = read(len)

    if (state == "TEXT") {
        gsub("'", "'\\''", _str)
    }
    if (state != "COMMENT") {
        fputs(_str)
    }
}
function file_exists(filename,  _junk) {
    if ((getline _junk < filename) >= 0) {
        close(filename)
        return 1
    }
    return 0
}
function dirname(path) {
    return sub(/\/[^\/]+\/*$/, "/", path) ? path : ""
}
function include(filename) {
    if (index(filename, "/") != 1) {  # if does not start with "/"
        filename = dirname(filenames[depth]) filename
    }
    if (!file_exists(filename)) {
        fail(12, "cannot include " filename ": not a file or not readable")
    }
    if (depth > MAX_DEPTH) {
        fail(13, "cannot include " filename ": exceeded maximum depth of " MAX_DEPTH)
    }
    buffs[depth] = buff
    states[depth] = state
    filenames[depth + 1] = filename
    depth++

    init()
    while ((getline buff < filename) > 0) {
        if (print_nl && state != "COMMENT") {
            puts("")
        }
        process_line()
    }
    end_text()
    close(filename)

    depth--
    buff = buffs[depth]
    state = states[depth]
}
function init() {
    buff = ""
    linenos[depth] = 0
    print_nl = 0
    start_text()
}
function start_text() {
    puts("")
    fputs("printf '%s' '")
    state = "TEXT"
}
function end_text() {
    if (state != "TEXT") { return }
    puts("'  #< " line_info())
    state = "UNDEF"
}
function process_line() {
    print_nl = 1
    linenos[depth]++

    while (buff != "") {
        print_nl = 1

        if (state == "TEXT" && match(buff, /<%/)) {
            flush(RSTART - 1)  # print buff before "<%"
            skip(2)  # skip "<%"

            flag = substr(buff, 1, 1)
            if (flag != "%") {
                end_text()
            }
            if (flag == "%") {  # <%%
                skip(1)
                fputs("<%")
            } else if (flag == "=") {  # <%=
                skip(1)
                fputs("__print ")
                state = "TAG"
            } else if (flag == "+") {  # <%+
                if (!match(buff, /[^%]%>/)) {
                    fail(11, "syntax error: <%+ must be closed on the same line")
                }
                filename = trim(substr(buff, 2, match(buff, /.-?%>/) - 1))
                skip(RSTART)
                include(filename)
                state = "TAG"
            } else if (flag == "#") {  # <%#
                state = "COMMENT"
            } else {
                state = "TAG"
            }
        } else if (state != "TEXT" && match(buff, /%>/)) {
            flag = RSTART > 1 ? substr(buff, RSTART - 1, 1) : ""

            if (flag == "%") {  # %%>
                flush(RSTART - 2)
                skip(1)
                flush(2)
            } else if (flag == "-") {  # -%>
                flush(RSTART - 2)
                skip(3)
                print_nl = 0
            } else {  # %>
                flush(RSTART - 1)
                skip(2)
            }
            if (flag != "%") {
                start_text()
            }
        } else {
            flush()
        }
    }
}
BEGIN {
    FS = ""
    depth = 0

    puts("#!" (SHELL ~ /\// ? SHELL : "/usr/bin/env " SHELL))
    puts("set -eu")
    puts("if ( set -o pipefail 2>/dev/null ); then set -o pipefail; fi")
    puts("__print() { printf '%s' \"$*\"; }")

    split(VARS, _lines, /\n/)
    for (_i in _lines) {
        puts(_lines[_i])
    }
    init()
}
{
    if (NR == 1) {
        filenames[0] = FILENAME  # this var is not defined in BEGIN so we must do it here
    }
    buff = $0
    process_line()

    if (print_nl && state != "COMMENT") {
        puts("")
    }
}
END {
    end_text()
}
AWK
)
AWK_ERR_FILTER=$(cat <<'AWK'
function line_info(lno,  _line, _i) {
    while ((getline _line < MAPFILE) > 0 && _i++ < lno) { }
    close(MAPFILE)
    return _line
}
{
    if (match($0, "^" SRCFILE ":( line)? ?[0-9]+:") && match(substr($0, 1, RLENGTH), /[0-9]+:$/)) {
        lno = substr($0, RSTART, RLENGTH - 1) + 0
        msg = substr($0, RSTART + RLENGTH + 1)  # v-- some shells duplicate filename
        msg = index(msg, SRCFILE ":") == 1 ? substr(msg, length(SRCFILE) + 3) : msg
        print(line_info(lno) ": " msg)
    } else if ($0 != "") {
        print($0)
    }
}
AWK
)
readonly AWK_CONVERTER AWK_ERR_FILTER

print_help() {
    sed -En '/^#---help---/,/^#---help---/p' "$SCRIPTPATH" | sed -E 's/^# ?//; 1d;$d;'
}

filter_shell_stderr() {
    $ESH_AWK \
        -v SRCFILE="$1" \
        -v MAPFILE="$2" \
        -- "$AWK_ERR_FILTER"
}

evaluate() {
    local srcfile="$1"
    local mapfile="$2"

    # This FD redirection magic is for swapping stdout/stderr back and forth.
    exec 3>&1
    { set +e; $ESH_SHELL "$srcfile" 2>&1 1>&3; echo $? >>"$mapfile"; } \
        | filter_shell_stderr "$srcfile" "$mapfile" >&2
    exec 3>&-

    return $(tail -n 1 "$mapfile")
}

convert() {
    local input="$1"
    local vars="$2"
    local map_file="${3:-"/dev/null"}"

    $ESH_AWK \
        -v MAX_DEPTH="$ESH_MAX_DEPTH" \
        -v SHELL="$ESH_SHELL" \
        -v MAP_FILE="$map_file" \
        -v VARS="$vars" \
        -- "$AWK_CONVERTER" "$input"
}

process() {
    local input="$1"
    local vars="$2"
    local evaluate="${3:-yes}"
    local ret=0 tmpfile mapfile

    if [ "$evaluate" = yes ]; then
        tmpfile=$(mktemp)
        mapfile=$(mktemp)

        convert "$input" "$vars" "$mapfile" > "$tmpfile" || ret=$?
        test $ret -ne 0 || evaluate "$tmpfile" "$mapfile" || ret=$?

        rm -f "$tmpfile" "$mapfile"
    else
        convert "$input" "$vars" || ret=$?
    fi
    return $ret
}

: ${ESH_AWK:="awk"}
: ${ESH_MAX_DEPTH:=3}
: ${ESH_SHELL:="/bin/sh"}
EVALUATE='yes'
OUTPUT=''

while getopts ':dho:s:V' OPT; do
    case "$OPT" in
        d) EVALUATE=no;;
        h) print_help; exit 0;;
        o) OUTPUT="$OPTARG";;
        s) ESH_SHELL="$OPTARG";;
        V) echo "$PROGNAME $VERSION"; exit 0;;
        '?') echo "$PROGNAME: unknown option: -$OPTARG" >&2; exit 10;;
    esac
done
shift $(( OPTIND - 1 ))

if [ $# -eq 0 ]; then
    printf "$PROGNAME: %s\n\n" 'missing argument <input>' >&2
    print_help >&2
    exit 10
fi

INPUT="$1"; shift
if [ "$INPUT" != '-' ] && ! [ -f "$INPUT" -a -r "$INPUT" ]; then
    echo "$PROGNAME: can't read $INPUT: not a file or not readable" >&2; exit 10
fi

# Validate arguments.
for arg in "$@"; do
    case "$arg" in
        *=*) ;;
        *) echo "$PROGNAME: illegal argument: $arg" >&2; exit 10;;
    esac
done

# Format variables into shell variable assignments.
vars=''; for item in "$@"; do
    vars="$vars\n${item%%=*}='$(
        printf %s "${item#*=}" | $ESH_AWK "{ gsub(/'/, \"'\\\\\\\''\"); print }"
    )'"
done

export ESH="$0"

if [ "${OUTPUT#-}" ]; then
    tmpfile="$(mktemp)"
    trap 'rm -f -- "$tmpfile"' EXIT HUP INT TERM
    process "$INPUT" "$vars" "$EVALUATE" > "$tmpfile"
    cat "$tmpfile" > "$OUTPUT"
else
    process "$INPUT" "$vars" "$EVALUATE"
fi
