bash-ini-parser/parse_ini

457 lines
17 KiB
Bash
Executable file

#!/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] <INI file>" >&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] <INI file>
Parse an INI-style file into array assignments which can be 'eval'ed into Bash.
Commonly used options:
-b <char>, --bound <char>
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 <char(s)>, --delim <char(s)>
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>, --prefix <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 <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 <darren (at) afterdark.org.uk>.
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: <http://gnu.org/licenses/gpl.html>. 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 $?