#!/bin/bash parser_getopts() { local DELIM_SET=0 while [[ ! -z "$1" ]]; do case "$1" in -b|-bound|--bound) shift if [[ -z "$1" ]]; then echo "${0##*/}: bound (-b) cannot be an empty value" >&2 return 1 elif ((${#1} > 1)); then echo "${0##*/}: bound (-b) must be a single character" >&2 return 1 else KEYVALUE_DELIM="$1" fi shift ;; -d|-delim|--delim) shift if [[ -z "$1" ]]; then VARIABLE_DELIM="" DELIM_SET=1 elif [[ -z "$VARIABLE_PREFIX" ]] && [[ "${1:0:1}" =~ [[:digit:]] ]]; then echo "${0##*/}: delim (-d) cannot begin with a number when prefix (-p) is empty" >&2 return 1 elif [[ "$1" =~ [^[:alnum:]_] ]]; then echo "${0##*/}: invalid characters in delim (-d) - alphanumerics and _ only" >&2 return 1 else VARIABLE_DELIM="$1" DELIM_SET=1 fi shift ;; -e|-export|--export) shift DECLARE_SCOPE="-x" ;; -global-name|--global-name) shift if [[ -z "$1" ]]; then echo "${0##*/}: global name (--global-name) cannot be an empty value" >&2 return 1 elif [[ "${1:0:1}" =~ [[:digit:]] ]]; then echo "${0##*/}: global name (--global-name) cannot begin with a number" >&2 return 1 elif [[ "$1" =~ [^[:alnum:]_] ]]; then echo "${0##*/}: only alphanumerics and _ allowed for global name (--global-name)" >&2 else CURRENT_SECTION="$1" fi shift ;; -h|-\?|-help|--help) parser_help return 2 ;; -l|-local|--local) shift DECLARE_SCOPE="-l" ;; -lowercase|--lowercase) shift CONVERT_CASE="-1" ;; -no-booleans|--no-booleans) shift USE_BOOLEANS="0" ;; -no-squash|--no-squash) shift SQUASH_SPACES=0 ;; -p|-prefix|--prefix) shift if [[ -z "$1" ]]; then if [[ "${VARIABLE_DELIM:0:1}" =~ [[:digit:]] ]]; then echo "${0##*/}: prefix (-p) cannot be empty if delim (-d) begins with a number" >&2 return 1 else VARIABLE_PREFIX="" if ((DELIM_SET == 0)); then VARIABLE_DELIM="" fi fi elif [[ "${1:0:1}" =~ [[:digit:]] ]]; then echo "${0##*/}: prefix (-p) cannot begin with a number" >&2 return 1 elif [[ "$1" =~ [^[:alnum:]_] ]]; then echo "${0##*/}: only alphanumerics and _ allowed for prefix (-p)" >&2 return 1 else VARIABLE_PREFIX="$1" fi shift ;; -textual-booleans|--textual-booleans) shift TEXTUAL_BOOLEANS="1" ;; -uppercase|--uppercase) shift CONVERT_CASE="1" ;; -v|-version|--version) parser_version return 2 ;; --) break ;; --*|-*) echo "${0##*/}: invalid option: $1" >&2 return 1 ;; *) break ;; esac done # Make sure we have an INI file after all the options are removed. if (($# == 0)) || (($# > 1)) || [[ -z "$1" ]]; then echo "Usage: ${0##*/} [options] " >&2 echo "Try: ${0##*/} --help" >&2 return 1 else INIFILE="$1" fi } parser_help() { #........1.........2.........3.........4.........5.........6.........7.........8 cat <<-EOF Usage: ${0##*/} [options] Parse an INI-style file into array assignments which can be 'eval'ed into Bash. Commonly used options: -b , --bound The bound character which delimits the key from the value in a property line of the INI file. The default is "=". This must be a single character and cannot be empty value. -d , --delim The character(s) (which may be an empty value) to use as a delimiter between the prefix and section name when defining the arrays. The default is "_", except where prefix is set to an empty value, in which case the default is also empty. Only alphanumerics and _ may be used with this option, and it may not begin with a number if prefix is empty. The delimiter may be converted to upper or lower case depending upon the use of '--uppercase' or '--lowercase'. -e, --export When declaring the arrays, export them to the environment. -h, -?, --help Show (this) help. -l, --local Declare the arrays as being local in scope, instead of the default of global scope. -p , --prefix The prefix of all the variables set when defining the arrays. The default is "INI". An empty prefix (denoted by "") implies '-d ""', but this can be overridden by explicitly specifying a delimiter with '-d'. Only alphanumerics and _ may be used with this option, and it may not be empty when delim ('-d') begins with a number. -v, --version Show version and copyright information. Lesser used options: --global-name The name of the 'global' section used when defining the arrays. Only alphanumerics and _ may be used with this option, which cannot be empty. The name may not begin with a number, and may be converted to upper or lower case depending upon the use of '--uppercase' or '--lowercase'. --lowercase When defining the arrays, the case of the prefix ('-p') name, delimiter and section name is kept as set. With this option all items are converted to lower case. The case of the propertie's keys/values is not affected. --no-booleans Normally, the parser interprites the presence of a key without an associated value as a boolean. Keys which are proceeded by "no_" are given a boolean 'false' value, while keys without a "no_" are given a 'true' value. With this option, the presence of a key without a value is considered a syntax error in the INI file. --no-squash Do not squash multiple consecutive blanks (which are later translated to a _) into a single space while reading section names and properties. --textual-booleans When defining the arrays, boolean keys are given a value of "0" or "1" (representing 'false' and 'true' respectivly). With this option the value of the key will be the text "false" or "true" instead. Ignored when '--no-booleans' is in use. --uppercase When defining the arrays, the case of the prefix ('-p') name, delimiter and section name is kept as set. With this option all items are converted to upper case. The case of the propertie's keys/values is not affected. Option processing ceases with the first non-option argument, or "--". EOF } parser_version() { #........1.........2.........3.........4.........5.........6.........7.........8 cat <<-EOF Bash INI file parser v0.1.0. Copyright (C) 2019 Darren 'Tadgy' Austin . Licensed under the terms of the GNU General Public Licence version 3. This program comes with ABSOLUTELY NO WARRANTY. For details and a full copy of the license terms, see: . This is free software - you can modify and redistribute it under the terms of the GPL v3. EOF } parse_ini() { # Bash v4.4+ is required. # if [[ -z "${BASH_VERSINFO[0]}" ]] || ((BASH_VERSINFO[0] < 4)); then if [[ -z "${BASH_VERSINFO[0]}" ]] || ((BASH_VERSINFO[0] < 4)); then echo "${0##*/}: minimum of bash v4 required" >&2 return 1 fi # Set defaults. local ACCEPTABLE_CHARS="[:blank:][:alnum:]_.+-" # Characters allowed in section and key names. Must be a valid regex bracket expression. local COMMENT_CHARS="#;" # Characters which indicate the start of a comment line. local CONVERT_CASE="0" # Whether to keep or convert section and key names to upper or loweer case. -1 = covert to lowercase, 0 = keep case, 1 = convert to uppercase. local CONVERT_CHARS="[:blank:].+-" # Characters from ACCEPTABLE_CHARS in section and key names that should be converted to _. Must be a valid regex bracket expression. local CURRENT_SECTION="global" # Name used for the 'global' section of the INI file. local DECLARE_SCOPE="-g" # The scope given in the array definitions. "-g" = global scope, "-l" = local scope, "-x" = export values. local KEYVALUE_DELIM="=" # Delimiter between key and value. Must be a single character. local SQUASH_SPACES="1" # Whether to squash multiple consecutive blanks into a single space. 0 = don't squash, 1 = do squash. local TEXTUAL_BOOLEANS="0" # Whether to use "false" and "true" for booleans. 0 = use "0" and "1", 1 = use "false" and "true". local USE_BOOLEANS="1" # Whether to allow the use of boolean values in the INI file. 0 = don't allow, 1 = do allow. local VARIABLE_PREFIX="INI" # Prefix for all variables. Note: case is not changed, even with CONVERT_CASE set. local VARIABLE_DELIM="_" # Delimiter between prefix and section name, unless VARIABLE_PREFIX is empty. # Variables. local BOOL_VALUE DELIM ERR IGNORE_SECTION=0 INIFD KEY LINE LINENUMBER=0 PREFIX REPLY VALUE declare INIFILE # Parse options. parser_getopts "$@" ERR=$? if ((ERR == 1)); then # And error occured. return 1 elif ((ERR == 2)); then # Help/version was showed, exit sucessfully. return 0 fi # If reading from stdin, don't try to open the FD as it's already open. if [[ "$INIFILE" == "-" ]]; then INIFD="1" else # File accessability checks. if [[ ! -e "$INIFILE" ]]; then echo "${0##*/}: no such file: $INIFILE" >&2 return 1 elif [[ ! -f "$INIFILE" ]]; then echo "${0##*/}: not a regular file: $INIFILE" >&2 return 1 elif [[ ! -r "$INIFILE" ]]; then echo "${0##*/}: permission denied: $INIFILE" >&2 return 1 fi # Open the INI file for reading. if ! exec {INIFD}<"$INIFILE"; then echo "${0##*/}: failed to open INI file: $INIFILE" >&2 return 1 fi fi # Extglob is required. shopt -s extglob # Convert case, if required. if ((CONVERT_CASE == -1)); then # Covert to lowercase. PREFIX="${VARIABLE_PREFIX,,}" DELIM="${VARIABLE_DELIM,,}" CURRENT_SECTION="${CURRENT_SECTION,,}" elif ((CONVERT_CASE == 1)); then # Convert to uppercase. PREFIX="${VARIABLE_PREFIX^^}" DELIM="${VARIABLE_DELIM^^}" CURRENT_SECTION="${CURRENT_SECTION^^}" else # Don't convert. PREFIX="$VARIABLE_PREFIX" DELIM="$VARIABLE_DELIM" fi # Output the 'global' section definition. # FIXME: If doing validation only, don't output declaration here. printf "declare %s -A %s%s%s\\n" "$DECLARE_SCOPE" "$PREFIX" "$DELIM" "$CURRENT_SECTION" # Parse the INI file. while :; do LINE="" # Construct a line of input to parse. while :; do # Read a line of input from the file descriptor. # The 'read' will do the job of removing leading whitespace from the line. read -r -u "$INIFD" REPLY || break 2 ((LINENUMBER++)) # Handle line continuations. if [[ "${REPLY: -1:1}" == "\\" ]]; then LINE+="${REPLY:0:-1}" continue else LINE+="$REPLY" break fi done # Ignore the line if it's a comment. # FIXME: Is the printf required here? [[ "$LINE" =~ ^[[:blank:]]*([$(printf "%q" "$COMMENT_CHARS")].*)*$ ]] && continue # Process the line. if [[ "${LINE:0:1}" == "[" ]]; then # Found the beginning of a section definition. # Check the format of the section definition. if [[ "${LINE: -1:1}" != "]" ]]; then echo "${0##*/}: line $LINENUMBER: unmatched [ in section definition - ignoring section" >&2 IGNORE_SECTION=1 continue elif [[ "${LINE:1:-1}" =~ [^$ACCEPTABLE_CHARS\[\]]* ]]; then echo "${0##*/}: line $LINENUMBER: invalid characters in section definition - ignoring section" >&2 IGNORE_SECTION=1 continue elif [[ -z "${LINE:1:-1}" ]] || [[ "${LINE:1:-1}" =~ ^[[:blank:]]+$ ]]; then echo "${0##*/}: line $LINENUMBER: empty section definition - ignoring section" >&2 IGNORE_SECTION=1 continue else # Strip the []s and any whitespace between the []s and the section name. LINE="${LINE/#\[*([[:space:]])/}" LINE="${LINE/%*([[:space:]])\]/}" # Squash multiple consecutive blanks into a single space. ((SQUASH_SPACES == 1)) && LINE="${LINE//+([[:blank:]])/ }" # Convert single or consecutive occurances of invalid characters into a single _. # LINE="${LINE//+([$CONVERT_CHARS])/_}" # Convert each occurance of invalid character into a _. LINE="${LINE//@([$CONVERT_CHARS])/_}" # Convert single or consecutive invalid characters into a single _ except for multiple _s already in line. # LINE="${LINE//+([${CONVERT_CHARS/_//}])/_}" # Convert case, if required. if ((CONVERT_CASE == -1)); then # Covert to lowercase. LINE="${LINE,,}" elif ((CONVERT_CASE == 1)); then # Convert to uppercase. LINE="${LINE^^}" fi # Output the associative array declaration. # FIXME: If doing validation only, don't output declaration here. printf "declare %s -A %s%s%s\\n" "$DECLARE_SCOPE" "$PREFIX" "$DELIM" "$LINE" # Keep track of the current section name. CURRENT_SECTION="$LINE" # Reset the ignore flag. IGNORE_SECTION=0 fi elif ((IGNORE_SECTION == 0)) && [[ "$LINE" != *$KEYVALUE_DELIM* ]]; then # Process the property definition as if it's a boolean. # If the value starts with a " or ' it must end with same. if [[ "${LINE:0:1}" =~ [\"\'] ]]; then if [[ "${LINE:0:1}" == "${VALUE: -1:1}" ]]; then # Strip the quotes as they're not needed. LINE="${LINE:1:-1}" else echo "${0##*/}: line $LINENUMBER: unmatched quotes - ignoring property" >&2 continue fi fi # Determine the boolean value. if [[ "${LINE:0:3}" == "no_" ]]; then LINE="${LINE:3:${#LINE} - 1}" if ((TEXTUAL_BOOLEANS == 0)); then BOOL_VALUE=0 else BOOL_VALUE="false" fi else if ((TEXTUAL_BOOLEANS == 0)); then BOOL_VALUE=1 else BOOL_VALUE="true" fi fi # Output the associative array element definition. if ((USE_BOOLEANS == 1)); then # printf "%s%s%s+=([\"%s\"]=\"%s\")\\n" "$PREFIX" "${PREFIX:+$DELIM}" "$CURRENT_SECTION" "$LINE" "$BOOL_VALUE" printf "%s%s%s[\"%s\"]=\"%s\"\\n" "$PREFIX" "${PREFIX:+$DELIM}" "$CURRENT_SECTION" "$LINE" "$BOOL_VALUE" else echo "${0##*/}: line $LINENUMBER: key without a value - ignoring property" >&2 continue fi elif ((IGNORE_SECTION == 0)); then # Process the property definition as a key/value pair. # Remove trailing whitespace from key part. LINE="${LINE/+([[:blank:]])$KEYVALUE_DELIM/$KEYVALUE_DELIM}" # Remove leading whitespace from value part. LINE="${LINE/$KEYVALUE_DELIM+([[:blank:]])/$KEYVALUE_DELIM}" # Extract the key and the value. KEY="${LINE%%$KEYVALUE_DELIM*}" VALUE="${LINE#*$KEYVALUE_DELIM}" # Squash multiple consecutive blanks into a single space. ((SQUASH_SPACES == 1)) && KEY="${KEY//+([[:blank:]])/ }" # If the value starts with a " or ' it must end with same. if [[ "${VALUE:0:1}" =~ [\"\'] ]]; then if [[ "${VALUE:0:1}" == "${VALUE: -1:1}" ]]; then # Strip the quotes as they're not needed. VALUE="${VALUE:1:-1}" else echo "${0##*/}: line $LINENUMBER: unmatched quotes - ignoring property" >&2 continue fi fi # Output the associative array element definition. # FIXME: If doing validation only, don't output declaration here. # FIXME: Have an option to have repeat sections/properties over-write previous ones rather than append. printf "%s%s%s[\"%s\"]+=\"%s\"\\n" "$PREFIX" "${PREFIX:+$DELIM}" "$CURRENT_SECTION" "$KEY" "$VALUE" else # FIXME: Make this debug output only. echo "Skipping line $LINENUMBER" >&2 true fi done # Close file descriptor for INI file. if ((INIFD != 1)); then exec {INIFD}<&- fi # Clean up the environment. unset INIFILE } parse_ini "$@" exit $?