#!/usr/bin/env bash ####################################################################### # SPDX-License-Identifier: MIT # Copyright (c) 2021 TekWizely & co-authors # # Use of this source code is governed by the MIT license. # See the accompanying LICENSE file, if present, or visit: # https://opensource.org/licenses/MIT ####################################################################### VERSION="v0.8.0" ####################################################################### # Bash-TPL: A Smart, Lightweight shell script templating engine # # Lets you mark up textual files with shell commands and variable # replacements, while minimally impacting your original file layout. # # Templates are compiled into shell scripts that you can invoke # (along with variables, arguments, etc.) to generate complete and # well-formatted output text files. # # Smart # # Encourages you to use extra indentation to write clean, well- # formatted templates, and smartly removes the indentations from the # generated template scripts. # # This results in both templates that are easily readable and # maintainable, and generated text files that look as good as if they # were written by hand. # # NOTE: Consistent Formatting # # The key to success with Bash-TPL indentation fix-up logic is # Consistent Formatting; using consistent indentation throughout your # template will yield best results. # # Learn More: # https://github.com/TekWizely/bash-tpl ####################################################################### function usage() { cat << USAGE Bash-TPL is a smart, lightweight shell script templating engine usage: bash-tpl [flags] [--] file bash-tpl [flags] - cat file | bash-tpl [flags] options: -h, --help show help screen --version show version -o, --output-file write to specified file (default: stdout) -- treat remaining options as positional arguments - read from stdin customize delimiters: --tag-delims 'xx xx' set tag delimiters (default: '<% %>') --tag-stmt-delim 'x' set tag statement delimiter (default: '%') --stmt-delim 'x+' set statement delimiter (default: '%') --stmt-block-delims 'x+ x+' set statement block delimiters defaults to statement delimiter if not explicitly set --txt-delim --text-delim 'x+[ ]?' (single trailing space allowed) set text delimiter (default: '% ') --dir-delim --directive-delim 'x+' set directive delimiter (default: '.') --cmt-delim --comment-delim 'x+' set template comment delimiter defaults to directive delimiter + '#' if not explicitly set --reset-delims reset all delimiters to defaults delim options provided after this option are honored supported environment variables: BASH_TPL_TAG_DELIMS BASH_TPL_TAG_STMT_DELIM BASH_TPL_STMT_DELIM BASH_TPL_STMT_BLOCK_DELIMS BASH_TPL_TEXT_DELIM BASH_TPL_DIR_DELIM BASH_TPL_CMT_DELIM example: $ echo 'Hello <% \$NAME %>' > test.tpl $ NAME="Chuck Norris" source <( bash-tpl test.tpl ) Hello Chuck Norris learn more: https://github.com/TekWizely/bash-tpl USAGE } ####################################################################### # Delim Functions ####################################################################### TAG_DELIM_REGEX='^([^[:blank:]])([^[:blank:]]) ([^[:blank:]])([^[:blank:]])$' TAG_STMT_DELIM_REGEX='^([^[:blank:]])$' STMT_DELIM_REGEX='^([^[:blank:]]+)$' STMT_BLOCK_DELIM_REGEX='^([^[:blank:]]+) ([^[:blank:]]+)$' STMT_BLOCK_TEXT_REGEX='^([^[:blank:]]+\ ?)$' # Optional trailing ' ' ## # reset_delims # function reset_delims() { TAG_START_DELIM1='<' TAG_START_DELIM2='%' TAG_STOP_DELIM1='%' TAG_STOP_DELIM2='>' TAG_STMT_DELIM='%' STMT_DELIM='%' TEXT_DELIM_UNDEFINED=1 TEXT_DELIM='' STMT_BLOCK_DELIM_UNDEFINED=1 STMT_BLOCK_START_DELIM='' STMT_BLOCK_STOP_DELIM='' DIRECTIVE_DELIM='.' COMMENT_DELIM_UNDEFINED=1 COMMENT_DELIM='' } ## # parse_tag_delims # $1 = delims # $2 = src (for error msg) # function parse_tag_delims() { if [[ "${1}" =~ $TAG_DELIM_REGEX ]]; then TAG_START_DELIM1="${BASH_REMATCH[1]}" TAG_START_DELIM2="${BASH_REMATCH[2]}" TAG_STOP_DELIM1="${BASH_REMATCH[3]}" TAG_STOP_DELIM2="${BASH_REMATCH[4]}" else echo "Error: Invalid or missing tag delimiter values for ${2-tag delims}: '${1}'" >&2 exit 1 fi } ## # parse_tag_stmt_delims # $1 = delim # $2 = src (for error msg) # function parse_tag_stmt_delim() { if [[ "${1}" =~ $TAG_STMT_DELIM_REGEX ]]; then TAG_STMT_DELIM="${BASH_REMATCH[1]}" else echo "Error: Invalid or missing tag stmt delimiter value for ${2-tag stmt delim}: '${1}'" >&2 exit 1 fi } ## # parse_stmt_delim # $1 = delim # $2 = src (for error msg) # function parse_stmt_delim() { if [[ "${1}" =~ $STMT_DELIM_REGEX ]]; then STMT_DELIM="${BASH_REMATCH[1]}" else echo "Error: Invalid or missing stmt delimiter value for ${2:-stmt delim}: '${1}'" >&2 exit 1 fi } ## # parse_stmt_block_delims # $1 = delims # $2 = src (for error msg) # function parse_stmt_block_delims() { if [[ "${1}" =~ $STMT_BLOCK_DELIM_REGEX ]]; then STMT_BLOCK_START_DELIM="${BASH_REMATCH[1]}" STMT_BLOCK_STOP_DELIM="${BASH_REMATCH[2]}" STMT_BLOCK_DELIM_UNDEFINED='' else echo "Error: Invalid or missing stmt-block delimiter values for ${2:-stmt-block delims}: '${1}'" >&2 exit 1 fi } ## # parse_text_delim - Uses STMT delim regex # $1 = delim # $2 = src (for error msg) # function parse_text_delim() { if [[ "${1}" =~ $STMT_BLOCK_TEXT_REGEX ]]; then TEXT_DELIM="${1}" TEXT_DELIM_UNDEFINED='' else echo "Error: Invalid or missing text delimiter value for ${2:-txt delim}: '${1}'" >&2 exit 1 fi } ## # parse_directive_delim - Uses STMT delim regex # $1 = delim # $2 = src (for error msg) # function parse_directive_delim() { if [[ "${1}" =~ $STMT_DELIM_REGEX ]]; then DIRECTIVE_DELIM="${1}" else echo "Error: Invalid or missing directive delimiter value for ${2:-dir delim}: '${1}'" >&2 exit 1 fi } ## # parse_comment_delim - Uses STMT delim regex # $1 = delim # $2 = src (for error msg) # function parse_comment_delim() { if [[ "${1}" =~ $STMT_DELIM_REGEX ]]; then COMMENT_DELIM="${1}" COMMENT_DELIM_UNDEFINED='' else echo "Error: Invalid or missing comment delimiter value for ${2:-cmt delim}: '${1}'" >&2 exit 1 fi } ## # reset_template_regexes # function reset_template_regexes() { # Fixup STMT_BLOCK delims - Default to STMT_DELIM if not set # if [[ -n "${STMT_BLOCK_DELIM_UNDEFINED}" ]]; then STMT_BLOCK_START_DELIM="${STMT_DELIM}" STMT_BLOCK_STOP_DELIM="${STMT_DELIM}" fi # Fixup TEXT delim - Default to STMT_DELIM followed by ' ' if not set # if [[ -n "${TEXT_DELIM_UNDEFINED}" ]]; then TEXT_DELIM="${STMT_DELIM} " # Note trailing space (' ') fi # Fixup COMMENT delim - Default to STMT_DELIM followed by '#' if not set # if [[ -n "${COMMENT_DELIM_UNDEFINED}" ]]; then COMMENT_DELIM="${STMT_DELIM}#" fi # # Create regexes # local d ds d1 d2 d3 d4 d="${DIRECTIVE_DELIM}" escape_regex d DIRECTIVE_REGEX="^([[:blank:]]*)${d}([a-zA-Z_-]+\??)(.*)\$" d="${COMMENT_DELIM}" escape_regex d COMMENT_REGEX="^([[:blank:]]*)${d}" d="${STMT_DELIM}" escape_regex d STATEMENT_REGEX="^([[:blank:]]*)${d}[[:blank:]]+(.+)\$" d="${STMT_BLOCK_START_DELIM}" escape_regex d STATEMENT_BLOCK_START_REGEX="^([[:blank:]]*)${d}[[:blank:]]*\$" d="${STMT_BLOCK_STOP_DELIM}" escape_regex d STATEMENT_BLOCK_STOP_REGEX="^([[:blank:]]*)${d}[[:blank:]]*\$" d="${TEXT_DELIM}" escape_regex d STATEMENT_BLOCK_TEXT_REGEX="^([[:blank:]]*)${d}([[:blank:]]*[^[:blank:]](.*))\$" TEXT_REGEX='^([[:blank:]]*)([^[:blank:]](.*))?$' d1="${TAG_START_DELIM1}" escape_regex d1 d2="${TAG_START_DELIM2}" escape_regex d2 d3="${TAG_STOP_DELIM1}" escape_regex d3 d4="${TAG_STOP_DELIM2}" escape_regex d4 ds="${TAG_STMT_DELIM}" escape_regex ds TAG_TEXT_REGEX="^([^${d1}]+|${d1}$|${d1}[^${d1}${d2}]+)(.*)" TAG_STD_REGEX="^${d1}${d2}((([^${d3}])|(${d3}[^${d4}]))*)${d3}${d4}(.*)" TAG_QUOTE_REGEX="^${d1}${d2}\"((([^\"])|(\"[^${d3}])|(\"${d3}[^${d4}]))*)\"${d3}${d4}(.*)" TAG_STATEMENT_REGEX="^${d1}${d2}${ds}((([^${d3}])|(${d3}[^${d4}]))*)${d3}${d4}(.*)" # printf "# ---> delim regexes:" # printf "# STATEMENT_BLOCK_START_REGEX: '%s'\n" "${STATEMENT_BLOCK_START_REGEX}" # printf "# STATEMENT_BLOCK_STOP_REGEX: '%s'\n" "${STATEMENT_BLOCK_STOP_REGEX}" # printf "# COMMENT_REGEX: '%s'\n" "${COMMENT_REGEX}" # printf "# DIRECTIVE_REGEX: '%s'\n" "${DIRECTIVE_REGEX}" # printf "# STATEMENT_REGEX: '%s'\n" "${STATEMENT_REGEX}" # printf "# TAG_TEXT_REGEX: '%s'\n" "${TAG_TEXT_REGEX}" # printf "# TAG_STD_REGEX: '%s'\n" "${TAG_STD_REGEX}" # printf "# TAG_QUOTE_REGEX: '%s'\n" "${TAG_QUOTE_REGEX}" # printf "# TAG_STATEMENT_REGEX: '%s'\n" "${TAG_STATEMENT_REGEX}" } ####################################################################### # Misc Functions ####################################################################### ## # trim # usage: trim varname # NOTE: Expects value to NOT contain '\n' # function trim() { read -r "$1" <<< "${!1}"$'\n' } ## # escape_string # Escapes value compatible with posix `printf %b` # usage: escape_string varname # function escape_string { # Escape '\' first since we'll be adding more later local e="${!1//$'\\'/\\}" # Some man pages mention \0NNN but in practice it seems \NNN is also works. e="${e//\\/\\\\}" e="${e//\'/\\0047}" e="${e//$'\a'/\\a}" e="${e//$'\b'/\\b}" e="${e//$'\f'/\\f}" e="${e//$'\n'/\\n}" e="${e//$'\r'/\\r}" e="${e//$'\t'/\\t}" e="${e//$'\v'/\\v}" printf -v "${1}" "'%s'" "${e}" } ## # escape_regex # usage: escape_regex varname # function escape_regex() { local result # shellcheck disable=SC2001 # Too complex for ${variable//search/replace} # shellcheck disable=SC2016 # Not using expansion, prefer single quotes # shellcheck disable=SC2034 # ref is used result=$(sed 's/[][\.|$(){}?+*^]/\\&/g' <<< "${!1}") printf -v "${1}" "%s" "${result}" } ## # normalize_directive # usage: normalize_directive varname # function normalize_directive() { local result # shellcheck disable=SC2034 # ref is used result=$(tr 'a-z_' 'A-Z-' <<< "${!1}") printf -v "${1}" "%s" "${result}" } ####################################################################### # STATES ####################################################################### STATES=() # empty => DEFAULT STATE="DEFAULT" # [ DEFAULT, MAYBE_TXT_BLOCK, TXT_BLOCK, START_STMT_BLOCK, STMT_BLOCK ] ## # push_state # function push_state() { STATES+=("${STATE}") STATE="${1}" } ## # pop_state # function pop_state() { if [[ ${#STATES[@]} -gt 0 ]]; then STATE="${STATES[${#STATES[@]} - 1]}" unset "STATES[${#STATES[@]}-1]" else STATE="DEFAULT" fi } ####################################################################### # TEXT_INDENTS ####################################################################### TEXT_INDENTS=() # empty => "" TEXT_INDENT="" ## # push_text_indent # function push_text_indent() { TEXT_INDENTS+=("${TEXT_INDENT}") TEXT_INDENT="${1}" } ## # pop_text_indent # function pop_text_indent() { if [[ ${#TEXT_INDENTS[@]} -gt 0 ]]; then TEXT_INDENT="${TEXT_INDENTS[${#TEXT_INDENTS[@]} - 1]}" unset "TEXT_INDENTS[${#TEXT_INDENTS[@]} - 1]" else TEXT_INDENT="" fi } ####################################################################### # STATEMENT_INDENTS ####################################################################### STATEMENT_INDENTS=() # empty => "" STATEMENT_INDENT="" ## # push_statement_indent # function push_statement_indent() { STATEMENT_INDENTS+=("${STATEMENT_INDENT}") STATEMENT_INDENT="${1}" } ## # pop_statement_indent # function pop_statement_indent() { if [[ ${#STATEMENT_INDENTS[@]} -gt 0 ]]; then STATEMENT_INDENT="${STATEMENT_INDENTS[${#STATEMENT_INDENTS[@]} - 1]}" unset "STATEMENT_INDENTS[${#STATEMENT_INDENTS[@]} - 1]" else STATEMENT_INDENT="" fi } ####################################################################### # BLOCK_INDENTS ####################################################################### BLOCK_INDENTS=() # empty => "" BLOCK_INDENT="" ## # push_block_indent # function push_block_indent() { BLOCK_INDENTS+=("${BLOCK_INDENT}") BLOCK_INDENT="${1}" } ## # pop_block_indent # function pop_block_indent() { if [[ ${#BLOCK_INDENTS[@]} -gt 0 ]]; then BLOCK_INDENT="${BLOCK_INDENTS[${#BLOCK_INDENTS[@]} - 1]}" unset "BLOCK_INDENTS[${#BLOCK_INDENTS[@]} - 1]" else BLOCK_INDENT="" fi } ####################################################################### # Print Functions ####################################################################### ## # print_statement # $1 = leading indentation # $2 = statement # function print_statement() { local indent="${1}" if [[ "${indent}" == "${BLOCK_INDENT}"* ]]; then indent="${indent/#$BLOCK_INDENT/}" fi printf "%s\n" "${BASE_STMT_INDENT}${STATEMENT_INDENT}${indent}${2}" } ## # print_text - generates a printf statement for template text # $1 = leading text indentation # $2 = text # $3 = leading stmt indentation [OPTIONAL] # function print_text() { local indent="${1}" if [[ "${indent}" == "${BLOCK_INDENT}"* ]]; then indent="${indent/#$BLOCK_INDENT/}" fi process_tags "${3-${BLOCK_INDENT}}" "${BASE_TEXT_INDENT}${TEXT_INDENT}${indent}${2}" } ####################################################################### # Process Functions ####################################################################### ## # process_tags # $1 = statement indentation # $2 = full line of text to process # function process_tags() { local stmt_indent line formats args arg quoted stmt_indent="${1}" line="${2}" formats="" args=() while [ -n "${line}" ]; do # echo "# LINE @ START: $(declare -p line)" >&2 if [[ "${line}" =~ $TAG_TEXT_REGEX ]]; then # echo "# TEXT TAG MATCH: $(declare -p BASH_REMATCH)" >&2 quoted="${BASH_REMATCH[1]}" escape_string quoted #printf -v quoted "%q" "${BASH_REMATCH[1]}" formats="${formats}%b" args+=("${quoted}") line="${BASH_REMATCH[2]}" elif [[ "${line}" =~ $TAG_QUOTE_REGEX ]]; then # echo "# QUOTE TAG MATCH: $(declare -p BASH_REMATCH)" >&2 formats="${formats}%s" args+=("\"${BASH_REMATCH[1]}\"") line="${BASH_REMATCH[6]}" elif [[ "${line}" =~ $TAG_STATEMENT_REGEX ]]; then # echo "# STMT TAG MATCH: $(declare -p BASH_REMATCH)" >&2 arg="${BASH_REMATCH[1]}" trim arg formats="${formats}%s" args+=("\"\$(${arg})\"") line="${BASH_REMATCH[5]}" # Check standard regex last as it's a super-set of quote and stmt regex # elif [[ "${line}" =~ $TAG_STD_REGEX ]]; then # echo "# STD TAG MATCH: $(declare -p BASH_REMATCH)" >&2 arg="${BASH_REMATCH[1]}" trim arg formats="${formats}%s" args+=("\"${arg}\"") line="${BASH_REMATCH[5]}" # Assume next character is TEXT - extract and process remainder # elif [[ "${line}" =~ (.)(.*) ]]; then # echo "# DEFAULT: Assuming first char is TEXT: $(declare -p line)" >&2 quoted="${BASH_REMATCH[1]}" escape_string quoted #printf -v quoted "%q" "${BASH_REMATCH[1]}" formats="${formats}%b" args+=("${quoted}") line="${BASH_REMATCH[2]}" fi # echo "# LINE @ END: $(declare -p line)" >&2 done local stmt if [[ ${#args[@]} -gt 0 ]]; then printf -v stmt "printf \"%s\\\\n\" %s" "${formats}" "${args[*]}" else printf -v stmt "printf \"\\\\n\"" fi print_statement "${stmt_indent}" "${stmt}" } DELIM_DIR_TAG_REGEX='[Tt][Aa][Gg]\s*=\s*"([^"]*)"' DELIM_DIR_TAG_STMT_REGEX='[Tt][Aa][Gg][_-]?[Ss][Tt][Mm][Tt]\s*=\s*"([^"]*)"' DELIM_DIR_STMT_REGEX='[Ss][Tt][Mm][Tt]\s*=\s*"([^"]*)"' DELIM_DIR_STMT_BLOCK_REGEX='[Ss][Tt][Mm][Tt][_-]?[Bb][Ll][Oo][Cc][Kk]\s*=\s*"([^"]*)"' DELIM_DIR_TXT_REGEX='[Tt][Xx][Tt]\s*=\s*"([^"]*)"' DELIM_DIR_TEXT_REGEX='[Tt][Ee][Xx][Tt]\s*=\s*"([^"]*)"' DELIM_DIR_DIR_REGEX='[Dd][Ii][Rr]\s*=\s*"([^"]*)"' DELIM_DIR_DIRECTIVE_REGEX='[Dd][Ii][Rr][Ee][Cc][Tt][Ii][Vv][Ee]\s*=\s*"([^"]*)"' DELIM_DIR_CMT_REGEX='[Cc][Mm][Tt]\s*=\s*"([^"]*)"' DELIM_DIR_COMMENT_REGEX='[Cc][Oo][Mm][Mm][Ee][Nn][Tt]\s*=\s*"([^"]*)"' ## # process_directive # $1 = leading_indent # $2 = directive # $3 = directive arg(s) # function process_directive() { local directive directive="${2}" normalize_directive directive case "${directive}" in INCLUDE | INCLUDE\?) local file_not_found_ok="" if [ "${directive}" = "INCLUDE?" ]; then file_not_found_ok="1" fi local indent="${1}" if [[ "${indent}" == "${BLOCK_INDENT}"* ]]; then indent="${indent/#$BLOCK_INDENT/}" fi local args args_arr args="${3}" trim args declare -a args_arr="(${args})" # shellcheck disable=SC2128 # We choose BASH_SOURCE vs BASH_SOURCE[0] for compatability "${BASH_SOURCE}" \ --text-indent "${BASE_TEXT_INDENT}${TEXT_INDENT}${indent}" \ --stmt-indent "${BASE_STMT_INDENT}${STATEMENT_INDENT}${indent}" \ --block-indent "${BASE_BLOCK_INDENT}${BLOCK_INDENT}" \ --tag-delims "${TAG_START_DELIM1}${TAG_START_DELIM2} ${TAG_STOP_DELIM1}${TAG_STOP_DELIM2}" \ --tag-stmt-delim "${TAG_STMT_DELIM}" \ --stmt-delim "${STMT_DELIM}" \ --stmt-block-delims "${STMT_BLOCK_START_DELIM} ${STMT_BLOCK_STOP_DELIM}" \ --txt-delim "${TEXT_DELIM}" \ --dir-delim "${DIRECTIVE_DELIM}" \ --cmt-delim "${COMMENT_DELIM}" \ ${file_not_found_ok:+'--file-not-found-ok'} \ "${args_arr[@]}" ;; DELIMS) # TAG # if [[ "${3}" =~ $DELIM_DIR_TAG_REGEX ]]; then parse_tag_delims "${BASH_REMATCH[1]}" 'DELIMS TAG directive' fi # TAG-STMT # if [[ "${3}" =~ $DELIM_DIR_TAG_STMT_REGEX ]]; then parse_tag_stmt_delim "${BASH_REMATCH[1]}" 'DELIMS TAG-STMT directive' fi # STMT # if [[ "${3}" =~ $DELIM_DIR_STMT_REGEX ]]; then parse_stmt_delim "${BASH_REMATCH[1]}" 'DELIMS STMT directive' fi # STMT-BLOCK # if [[ "${3}" =~ $DELIM_DIR_STMT_BLOCK_REGEX ]]; then parse_stmt_block_delims "${BASH_REMATCH[1]}" '"DELIMS STMT-BLOCK directive' fi # TEXT # if [[ "${3}" =~ $DELIM_DIR_TXT_REGEX || "${3}" =~ $DELIM_DIR_TEXT_REGEX ]]; then parse_text_delim "${BASH_REMATCH[1]}" 'DELIMS TEXT directive' fi # DIRECTIVE # if [[ "${3}" =~ $DELIM_DIR_DIR_REGEX || "${3}" =~ $DELIM_DIR_DIRECTIVE_REGEX ]]; then parse_directive_delim "${BASH_REMATCH[1]}" 'DELIMS DIR directive' fi # COMMENT # if [[ "${3}" =~ $DELIM_DIR_CMT_REGEX || "${3}" =~ $DELIM_DIR_COMMENT_REGEX ]]; then parse_comment_delim "${BASH_REMATCH[1]}" 'DELIMS CMT directive' fi # Apply changes # reset_template_regexes ;; RESET-DELIMS) reset_delims reset_template_regexes ;; *) # unsupported directive echo "Error: Unknown directive: '${directive}' - Skipping" >&2 ;; esac } function debug_array() { printf "[" local need_comma="" while [[ ${#@} -gt 0 ]]; do if [ -n "${need_comma:-}" ]; then printf ", " fi if [ -n "${1:-}" ]; then printf "'%q'" "${1}" else printf "''" fi need_comma=1 shift done printf "]" } ## # debug_state logs the state of the global variables. # To use this set the BASH_TPL_DEBUG variable to a non-empty value # when invoking the script. # TODO Track source input file+line numbers # $1 = template line | EOF # function debug_state() { printf "#<< ---------------\n" printf "#LINE TEXT : '%s'\n" "${1-}" printf "#STATE : %s\n" "${STATE:-}" printf "#STATES : %s\n" "$(debug_array "${STATES[@]}")" printf "#TEXT_INDENT : %q\n" "${TEXT_INDENT:-}" printf "#TEXT_INDENTS : %s\n" "$(debug_array "${TEXT_INDENTS[@]}")" printf "#STATEMENT_INDENT : %q\n" "${STATEMENT_INDENT:-}" printf "#STATEMENT_INDENTS: %s\n" "$(debug_array "${STATEMENT_INDENTS[@]}")" printf "#BLOCK_INDENT : %q\n" "${BLOCK_INDENT:-}" printf "#BLOCK_INDENTS : %s\n" "$(debug_array "${BLOCK_INDENTS[@]}")" printf "#TEXT_BLOCK_LINES_INDENT : %q\n" "${TEXT_BLOCK_LINES_INDENT}" printf "#TEXT_BLOCK_LINES_INDENT_SET : %q\n" "${TEXT_BLOCK_LINES_INDENT_SET}" printf "#STATEMENT_BLOCK_LINES_INDENT : %q\n" "${STATEMENT_BLOCK_LINES_INDENT}" printf "#STATEMENT_BLOCK_LINES_INDENT_STATE : %q\n" "${STATEMENT_BLOCK_LINES_INDENT_STATE}" printf "#>> ---------------\n" } function process_line() { [ -n "${BASH_TPL_DEBUG:-}" ] && debug_state ${@+"$@"} state_"${STATE}" ${@+"$@"} } function process_stdin() { local line while IFS="" read -r line || [ -n "${line}" ]; do process_line "${line}" done # EOF - Notify states # Call with no args # while [[ "${STATE}" != "DEFAULT" ]]; do process_line done process_line # DEFAULT } ####################################################################### # State Handler Functions ####################################################################### ## # state_DEFAULT # Not inside any blocks # Assumes *_INDENT and STATE arrays are empty # function state_DEFAULT() { [[ ${#@} -gt 0 ]] || return # Exit early on EOF # Line is a statement # if [[ "${1}" =~ $STATEMENT_REGEX ]]; then print_statement "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" push_statement_indent "${BASH_REMATCH[1]}" push_state "MAYBE_TXT_BLOCK" # Line is a statement block start # elif [[ "${1}" =~ $STATEMENT_BLOCK_START_REGEX ]]; then push_statement_indent "${BASH_REMATCH[1]}" push_state "START_STMT_BLOCK" # Line is a directive # elif [[ "${1}" =~ $DIRECTIVE_REGEX ]]; then process_directive "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" # Line is a comment # elif [[ "${1}" =~ $COMMENT_REGEX ]]; then : # Comments do not generate output # Line is text # NOTE : Check LAST because regex always matches # elif [[ "${1}" =~ $TEXT_REGEX ]]; then print_text "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" fi } TEXT_BLOCK_LINES=() TEXT_BLOCK_LINES_INDENT="" TEXT_BLOCK_LINES_INDENT_SET="" ## # state_MAYBE_TXT_BLOCK # Previous line was a statement # We might be starting a text block # NOTE: Assumes # push_state "MAYBE_TXT_BLOCK" # push_statement_indent # function state_MAYBE_TXT_BLOCK() { # If there's a line to process (i.e. not EOF) # if [[ ${#@} -gt 0 ]]; then # Current line is empty # Considered text block content, # but doesn't contribute to indentation tracking # if [[ "${1}" == "" ]]; then TEXT_BLOCK_LINES+=("${1}") # Save line return # Current line is a block-end statement, # i.e. it's a statement at the same indentation as the start statement # elif [[ "${1}" =~ $STATEMENT_REGEX && "${BASH_REMATCH[1]}" == "${STATEMENT_INDENT}" ]]; then # We've saved a FULL text block ! # Use computed indentation # push_text_indent "${TEXT_INDENT}${STATEMENT_INDENT/#$BLOCK_INDENT/}" # Additive push_statement_indent "${TEXT_BLOCK_LINES_INDENT}" push_block_indent "${TEXT_BLOCK_LINES_INDENT}" local state_marker=${#STATES[@]} # Save for cleanup push_state "TXT_BLOCK" # Text blocks can be nested, so save lines and cleanup *before* processing # local lines=("${TEXT_BLOCK_LINES[@]}") TEXT_BLOCK_LINES=() TEXT_BLOCK_LINES_INDENT="" TEXT_BLOCK_LINES_INDENT_SET="" # Process saved lines now in new state # local line for line in "${lines[@]}"; do process_line "${line}" done # Clean up our TXT_BLOCK state and any other danglers # while [[ ${#STATES[@]} -gt $state_marker ]]; do process_line # EOF done # Clean up our MAYBE_TXT_BLOCK state # pop_statement_indent pop_state # Process close block in parent context # process_line "${1}" return # Capture line indentation for tracking # TEXT_REGEX is perfect for this, so just re-use it # NOTE: Regex always matches # elif [[ "${1}" =~ $TEXT_REGEX ]]; then TEXT_BLOCK_LINES+=("${1}") # Save line # If current line is indented # if [[ "${BASH_REMATCH[1]}" != "${STATEMENT_INDENT}" && "${BASH_REMATCH[1]}" == "${STATEMENT_INDENT}"* ]]; then # If first time through # if [[ "${TEXT_BLOCK_LINES_INDENT_SET}" == "" ]]; then # Track current indentation # TEXT_BLOCK_LINES_INDENT="${BASH_REMATCH[1]}" TEXT_BLOCK_LINES_INDENT_SET="1" return # If current line is indented SAME OR LESS than tracked # elif [[ "${TEXT_BLOCK_LINES_INDENT}" == "${BASH_REMATCH[1]}"* ]]; then # Update tracked indentation (may set to same value) # TEXT_BLOCK_LINES_INDENT="${BASH_REMATCH[1]}" return # If current line is indented MORE than tracked # elif [[ "${BASH_REMATCH[1]}" == "${TEXT_BLOCK_LINES_INDENT}"* ]]; then # No change # return # Neither line is a subset of the other # else : # Here for completeness fi # Current line is NOT indented # else : # Here for completeness fi fi # EOF # else : # Fall through fi # If we haven't returned by now, then we're not in a text block # Discard saved state and process saved lines # pop_statement_indent pop_state # Text blocks can be nested, so save lines and cleanup *before* processing # local lines=("${TEXT_BLOCK_LINES[@]}") # Clean up # TEXT_BLOCK_LINES=() TEXT_BLOCK_LINES_INDENT="" TEXT_BLOCK_LINES_INDENT_SET="" # Process saved lines now in parent context # TODO "push" these back onto primary line-processing stream? # local line for line in "${lines[@]}"; do process_line "${line}" done } ## # state_TXT_BLOCK # NOTE: Assumes # Called within MAYBE_TXT_BLOCK with a complete block to process # Will NOT be called with TXT Block Close # Every line has a minimum indentation of BLOCK_INDENT # push_state TXT_BLOCK # push_text_indent # push_statement_indent # push_block_indent # function state_TXT_BLOCK() { # EOF # if [[ ${#@} -eq 0 ]]; then # End of text block # Discard saved state # pop_text_indent pop_statement_indent pop_block_indent pop_state # Current line is empty # elif [[ "${1}" == "" ]]; then process_tags "${BLOCK_INDENT}" "" # Current line is a statement # elif [[ "${1}" =~ $STATEMENT_REGEX ]]; then print_statement "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" push_statement_indent "${BASH_REMATCH[1]}" push_state "MAYBE_TXT_BLOCK" # Current line is a statement Start Block # elif [[ "${1}" =~ $STATEMENT_BLOCK_START_REGEX ]]; then push_statement_indent "${BASH_REMATCH[1]}" push_state "START_STMT_BLOCK" # Current line is a directive # elif [[ "${1}" =~ $DIRECTIVE_REGEX ]]; then process_directive "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" # Line is a comment # elif [[ "${1}" =~ $COMMENT_REGEX ]]; then : # Comments do not generate output # Line is text # NOTE: Regex always matches # elif [[ "${1}" =~ $TEXT_REGEX ]]; then print_text "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" fi } STATEMENT_BLOCK_LINES=() STATEMENT_BLOCK_LINES_INDENT="" STATEMENT_BLOCK_LINES_INDENT_STATE="" ## # state_START_STMT_BLOCK # NOTE: Assumes # push_state "START_STMT_BLOCK" # push_statement_indent # function state_START_STMT_BLOCK() { # If there's a line to process (i.e. not EOF) # if [[ ${#@} -gt 0 ]]; then # Current line is empty # Considered statement block content, # but doesn't contribute to indentation tracking # if [[ "${1}" == "" ]]; then STATEMENT_BLOCK_LINES+=("${1}") # Save line return # Current line is a statement block end # elif [[ "${1}" =~ $STATEMENT_BLOCK_STOP_REGEX ]]; then # If indentation does not match block-open, then error # TODO Track line numbers for better reporting # if [[ "${BASH_REMATCH[1]}" != "${STATEMENT_INDENT}" ]]; then echo "Error: stmt-block close indentation does not match open" >&2 exit 1 fi # We've saved a FULL statement block ! # Is it fully indented? # if [[ "${STATEMENT_BLOCK_LINES_INDENT_STATE}" == "1" ]]; then # Use computed indentation # push_text_indent "${TEXT_INDENT}${STATEMENT_INDENT/#$BLOCK_INDENT/}" # Additive push_block_indent "${STATEMENT_BLOCK_LINES_INDENT}" else # If not consistently indented, default to no indent # TODO Print warning? # push_text_indent "" pop_statement_indent push_statement_indent "" push_block_indent "" fi # Process the saved lines # NOTE: Statement block end (+ cleanup) will be processed by STMT_BLOCK handler # pop_state push_state "STMT_BLOCK" # Process saved lines now in new state # Statement blocks do not nest, so we use global and cleanup *after* # local line for line in "${STATEMENT_BLOCK_LINES[@]}"; do process_line "${line}" done # Clean up our STMT_BLOCK state # process_line # EOF # Clean up # STATEMENT_BLOCK_LINES=() STATEMENT_BLOCK_LINES_INDENT="" STATEMENT_BLOCK_LINES_INDENT_STATE="" # Capture line indentation for tracking # TEXT_REGEX is perfect for this, so just re-use it # NOTE: Regex always matches # elif [[ "${1}" =~ $TEXT_REGEX ]]; then STATEMENT_BLOCK_LINES+=("${1}") # Save line # If current line is indented (or even) # if [[ "${BASH_REMATCH[1]}" == "${STATEMENT_INDENT}"* ]]; then # If first time through # if [[ "${STATEMENT_BLOCK_LINES_INDENT_STATE}" == "" ]]; then # Track current indentation # STATEMENT_BLOCK_LINES_INDENT="${BASH_REMATCH[1]}" STATEMENT_BLOCK_LINES_INDENT_STATE="1" # If still working with fully indented block # elif [[ "${STATEMENT_BLOCK_LINES_INDENT_STATE}" == "1" ]]; then # If current line is indented SAME OR LESS than tracked # if [[ "${STATEMENT_BLOCK_LINES_INDENT}" == "${BASH_REMATCH[1]}"* ]]; then # Update tracked indentation (may set to same value) # STATEMENT_BLOCK_LINES_INDENT="${BASH_REMATCH[1]}" # If current line is indented MORE than tracked # elif [[ "${BASH_REMATCH[1]}" == "${STATEMENT_BLOCK_LINES_INDENT}"* ]]; then # No change # : # Neither line is a subset of the other # else STATEMENT_BLOCK_LINES_INDENT_STATE="2" fi fi # Current line is NOT indented (or even) # else STATEMENT_BLOCK_LINES_INDENT_STATE="3" fi fi # EOF # else # EOF before close block reached is an error # TODO Track line numbers for better reporting # echo "Error: Missing stmt-block close ('${STMT_BLOCK_STOP_DELIM}')" >&2 exit 1 fi } ## # state_STMT_BLOCK # NOTE: Assumes # Called within START_STMT_BLOCK with a complete block to process # Will NOT be called with STMT Block Close # push_state STMT_BLOCK # push_text_indent # push_statement_indent # push_block_indent # function state_STMT_BLOCK() { # EOF # if [[ ${#@} -eq 0 ]]; then # End of statement block # Discard saved state # pop_text_indent pop_statement_indent pop_block_indent pop_state # Current line is empty # elif [[ "${1}" == "" ]]; then # TODO Do we need a flag to print BASE_STMT_INDENT when included? # printf "\n" # Line is text # elif [[ "${1}" =~ $STATEMENT_BLOCK_TEXT_REGEX ]]; then print_text "${BLOCK_INDENT}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[1]}" # Line is assumed to be a statement # TEXT_REGEX is perfect for this, so just re-use it # NOTE: Regex always matches # elif [[ "${1}" =~ $TEXT_REGEX ]]; then print_statement "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" fi } ####################################################################### # Main ####################################################################### function version() { printf "%s\n" "${VERSION}" } ## # parse_env_delims - Set Delims from Environment Vars # function parse_env_delims() { if [ -n "${BASH_TPL_TAG_DELIMS}" ]; then parse_tag_delims "${BASH_TPL_TAG_DELIMS}" "BASH_TPL_TAG_DELIMS" fi if [ -n "${BASH_TPL_TAG_STMT_DELIM}" ]; then parse_tag_stmt_delim "${BASH_TPL_TAG_STMT_DELIM}" "BASH_TPL_TAG_STMT_DELIM" fi if [ -n "${BASH_TPL_STMT_DELIM}" ]; then parse_stmt_delim "${BASH_TPL_STMT_DELIM}" "BASH_TPL_STMT_DELIM" fi if [ -n "${BASH_TPL_STMT_BLOCK_DELIMS}" ]; then parse_stmt_block_delims "${BASH_TPL_STMT_BLOCK_DELIMS}" "BASH_TPL_STMT_BLOCK_DELIMS" fi if [ -n "${BASH_TPL_TEXT_DELIM}" ]; then parse_text_delim "${BASH_TPL_TEXT_DELIM}" "BASH_TPL_TEXT_DELIM" fi if [ -n "${BASH_TPL_DIR_DELIM}" ]; then parse_directive_delim "${BASH_TPL_DIR_DELIM}" "BASH_TPL_DIR_DELIM" fi if [ -n "${BASH_TPL_CMT_DELIM}" ]; then parse_comment_delim "${BASH_TPL_CMT_DELIM}" "BASH_TPL_CMT_DELIM" fi } ## # parse_args # $@ args to parse # # Stores positional args in global array __ARGS[@] # function parse_args() { __ARGS=() # Global while (($#)); do case "$1" in -h | --help) usage exit 0 ;; --version) version exit 0 ;; -o | --output-file) if [ -n "${2}" ]; then OUTPUT_FILE="${2}" else echo "Error: Invalid or missing value for --output-file: '${2}'" >&2 exit 1 fi shift 2 ;; --reset-delims) # Reset delims immediately - Any delim flags after this will be honored # reset_delims shift ;; --tag-delims) parse_tag_delims "$2" "$1" shift 2 ;; --tag-stmt-delim) parse_tag_stmt_delim "$2" "$1" shift 2 ;; --stmt-delim) parse_stmt_delim "$2" "$1" shift 2 ;; --stmt-block-delims) parse_stmt_block_delims "$2" "$1" shift 2 ;; --txt-delim | --text-delim) parse_text_delim "$2" "$1" shift 2 ;; --dir-delim | --directive-delim) parse_directive_delim "$2" "$1" shift 2 ;; --cmt-delim | --comment-delim) parse_comment_delim "$2" "$1" shift 2 ;; --text-indent) BASE_TEXT_INDENT="${2}" shift 2 ;; --stmt-indent) BASE_STMT_INDENT="${2}" shift 2 ;; --block-indent) BASE_BLOCK_INDENT="${2}" shift 2 ;; --file-not-found-ok) # internal flag to support .INCLUDE? FILE_NOT_FOUND_OK=1 shift ;; -) __ARGS+=("$1") shift ;; --) shift while (($#)); do __ARGS+=("$1") shift done ;; --* | -*) # unsupported flags echo "Error: unknown flag: '$1'; use -h for help" >&2 exit 1 ;; *) # preserve positional arguments __ARGS+=("$1") shift ;; esac done } function main() { OUTPUT_FILE="" BASE_TEXT_INDENT="" BASE_STMT_INDENT="" BASE_BLOCK_INDENT="" reset_delims parse_env_delims FILE_NOT_FOUND_OK="" if [ -n "${BASH_TPL_FILE_NOT_FOUND_OK}" ]; then FILE_NOT_FOUND_OK="1" fi parse_args "$@" set -- "${__ARGS[@]}" unset __ARGS # No file argument # if [[ -z "${1}" ]]; then # Nothing waiting on stdin # if [[ -t 0 ]]; then usage exit 1 fi else # File argument is explicitly stdin # if [[ "${1}" == '-' ]]; then shift else # File argument points to non-existing/readable file # if [[ ! -r "${1}" ]]; then if [ -z "${FILE_NOT_FOUND_OK}" ]; then echo "File not found: '${1}'" >&2 exit 1 else # Fail silently, no message, no error code exit 0 fi fi # File argument is good, re-route it to stdin # exec < "${1}" shift fi fi reset_template_regexes if [[ -n "${OUTPUT_FILE}" ]]; then exec > "${OUTPUT_FILE}" fi process_stdin return 0 # ALL OK } # Only process main logic if not being sourced (ie tested) # (return 0 2> /dev/null) || main "$@"