Created
November 16, 2012 17:34
-
-
Save hercynium/4089250 to your computer and use it in GitHub Desktop.
declarative getopt in bash
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| set -o errexit | |
| set -o nounset | |
| set -o pipefail | |
| source /bath/to/bash-getopt | |
| bash-getopt "$@" <<END_OPTS | |
| FOO=f|foo:NAME "$foo" "this is a description for the option. it can span | |
| multiple lines. foo has a single : so it requires a value when used." | |
| BAR=b|bar::PATH "" "bar has :: after it, so passing a value is optional" | |
| ZIP=z|zip "zip has no :, so it is a boolean flag. flag options only need | |
| a description; the default is *always* the empty string" | |
| END_OPTS | |
| # option values are in the specified vars | |
| echo "[$FOO] [$BAR] [$ZIP]" | |
| # leftover/positional args are in an ARGV array | |
| for a in "${ARGV[@]}"; do echo "[$a]"; done | |
| # a function is created to display usage info: | |
| usage | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # vim: set ts=4 sw=4 tw=0 ft=sh et : | |
| ################################################################################ | |
| # | |
| # NAME: bash-getopt (aka better-bash-getopt) | |
| # | |
| # This is (IMO) a better way to do option processing from bash. | |
| # | |
| # USAGE: | |
| # | |
| # Source this file from any bash script, then process your options like this: | |
| # | |
| # # assume $foo is set to some sane default earlier in the script | |
| # bash-getopt "$@" <<END_OPTS | |
| # | |
| # FOO=f|foo:NAME "$foo" "this is a description for the option. it can span | |
| # multiple lines. foo has a single : so it requires a value when used." | |
| # | |
| # BAR=b|bar::PATH "" "bar has :: after it, so passing a value is optional" | |
| # | |
| # ZIP=z|zip "zip has no :, so it is a boolean flag. flag options only need | |
| # a description; the default is *always* the empty string" | |
| # | |
| # END_OPTS | |
| # | |
| # Upon successful processing of the above options, the calling script will | |
| # have the variables $FOO, $BAR, and $ZIP available. Any positional arguments | |
| # found will be put into an array, ${ARGV[@]}. The original $@ will be | |
| # unmodified. In addition, there will be a _usage function available which | |
| # the caller may use if, for example, they do additional validation of the | |
| # values in the option variables (say, checking that $BAR is a valid path) | |
| # | |
| # If there is an error, a well-formatted usage message will be displayed on | |
| # STDERR and the script will exit. -h and --help options are automatically | |
| # generated and display help on STDOUT when used. | |
| # | |
| # The behavior/semantics of the option processing is identical to GNU getopt, | |
| # as that is actually what is called to finally do the option processing. | |
| # | |
| # COMPATIBILITY: | |
| # | |
| # This has currently been tested and is known to work on the following | |
| # OS/Bash/getopt configurations: | |
| # | |
| # Mac OS X 10.7.5, bash 3.2.48, GNU getopt (enhanced) 1.1.4 (from homebrew) | |
| # CentOS 6.3, bash 4.1.2, GNU getopt (enhanced) 1.1.4 | |
| # | |
| # It will not work with the BSD-style getopt shipped with Mac OS X. You'll | |
| # need to install GNU getopt and put it in your path before the system | |
| # getopt. I doubt it will work with versions of bash prior to 3.x | |
| # | |
| # Except GNU getopt, no other external programs are used. | |
| # | |
| # DESCRIPTION: | |
| # | |
| # This is an implementation of an idea I've had kicking around for a while. | |
| # | |
| # Using named (vs positional) options in shell scripts involves writing way | |
| # too much boilerplate, repetition of information, and is just an overall | |
| # PITA. | |
| # | |
| # Combine that with the limitations of bash's builtin getopts, and the | |
| # shortcomings of GNU getopt, having to edit multiple places in a script | |
| # when adding or changing options, and the fact that people regularly take | |
| # shortcuts and/or make mistakes... you get the idea. | |
| # | |
| # bash-getopt is designed to be easy to use, relatively intuitive, and | |
| # to eliminate repeated information and unnecessary boilerplate as much | |
| # as possible. | |
| # | |
| # It is still being fleshed out, but the current implementation is reasonably | |
| # complete, and seems to be quite stable and fast. | |
| # | |
| # OPTION DEFINITIONS | |
| # | |
| # The lines in the "heredoc" in the example above are called "Option | |
| # Definitions". An option definition has either two or three fields, | |
| # depending on the type of option (which is defined in the first field) | |
| # | |
| # The first field is the "Option Specification" and has a format and syntax | |
| # described in more detail below. The specification defines, among other | |
| # things, the "type" of the option - boolean flag, value required, or value | |
| # optional. | |
| # | |
| # After the specification field, boolean flag options only have one | |
| # additional field, a description for including in usage/help output. | |
| # The two value option types have two more fields, the first being the | |
| # default value for when the option is not used by the caller of the | |
| # script, the second being the afore-mentioned description field. | |
| # | |
| # boolean/flag options do not have a field for setting a default because | |
| # it's simply not necessary. If the option was used, the value is 1. If | |
| # the option was not used, the value is the empty string, "". | |
| # | |
| # If a default or description field will contain spaces, it must be quoted. | |
| # Standard shell quoting rules apply. Because of the way the definitions are | |
| # processed, a description can span multiple lines, but the extra whitespace | |
| # will get collapsed. Also, there can be blank-lines between option | |
| # definitions for better readability. | |
| # | |
| # A previous version of bash-getopt even supported bash-style comments in | |
| # between the definitions, but the code to do that properly has been deemed | |
| # more trouble than the feature is worth, for now. | |
| # | |
| # OPTION SPECIFICATIONS | |
| # | |
| # An "option specification" allows you to succinctly express a lot of | |
| # information about your program's command-line options. Using one of the | |
| # examples above, the format for an option specification is parsed like this: | |
| # | |
| # FOO=f|foo:NAME | |
| # | | | | | | |
| # var name ---+ | | | | | |
| # short name ----+ | | | | |
| # long name --------+ | | | |
| # type indicator -----+ | | |
| # value unit -----------+ | |
| # | |
| # In this case, the value of this option will be assigned to a variable named | |
| # $FOO, you can use this option with either -f or --foo, the type indicates | |
| # that this is a value-required option, and in the help text the value will | |
| # referred to as NAME. | |
| # | |
| # The variable name can be composed of any characters that are typically | |
| # valid for a variable name, [_0-9a-zA-Z]. The variable name is required | |
| # because making it optional just doesn't seem worth the effort and code. | |
| # | |
| # The short and long names for the option are separated by a pipe ("|"). | |
| # There can only be one of each type of name for the option but you can omit | |
| # one or the other if you wish (examples: FOO=f:NAME or FOO=foo:NAME) | |
| # | |
| # The short name can be any character in [0-9a-zA-Z]. I might be able to | |
| # allow a single dash as well, but I haven't yet tested it. | |
| # | |
| # The long name can be composed of any characters that are typically | |
| # recognized as valid in an option name, [-_0-9a-zA-Z] but it must begin | |
| # with a letter or number and must not end with a dash ("-") | |
| # | |
| # The type indicator can be zero, one, or two colons (":"). The indicator | |
| # has the same semantics as it does with GNU getopt: A single colon indicates | |
| # that if the option is used, a value *must* be supplied. Two indicates that | |
| # if the option is used, a value *may* be supplied. No colons indicate that | |
| # the option is a boolean flag, and using it results in a value of "1" and | |
| # not using it results in a value of the empty string (""). | |
| # | |
| # The "value unit" is used when showing usage or help text to indicate to the | |
| # user what the value represents. For example, the usage will show this for | |
| # foo: "--foo=NAME". The value unit can only be specified when the option | |
| # type is one that takes a value; It can't be used with boolean/flag options | |
| # because it makes no sense. (ok, very *little* sense). The value unit is | |
| # optional. Simply omit it if you do not want one. (example: FOO=f|foo:) | |
| # | |
| ################################################################################ | |
| # this file should only ever be sourced. | |
| # But... if it's executed directly, let's try to helpful! | |
| if [[ "$0" == "${BASH_SOURCE[0]}" ]]; then | |
| _show_help () { | |
| echo -n "SHOWING HELP FOR ${BASH_SOURCE[0]}..." | |
| sed -nr '/^########/,/^########/{s/^#*//;p}' "${BASH_SOURCE[0]}" | |
| } | |
| if [[ -t 1 ]]; then | |
| # if STDOUT is connected to a terminal, show help using a pager | |
| _show_help | "${PAGER-less}" | |
| else | |
| # if it isn't a terminal, just spew to STDERR | |
| _show_help >&2 | |
| fi | |
| exit 1 | |
| fi | |
| ### | |
| ### TODO: see if the utility functions in this script (warn,debug,is-true,etc) | |
| ### can either be used to augment Ed's utils, or be replaced by them. | |
| ### | |
| # the stuff below, I'm just thinking about, for now. I'm not certain there | |
| # won't be unexpected difficulties with changing shell opts in sourced code | |
| # and trying to restore them *correctly* before returning to the caller. | |
| # I wonder if something like this could work: | |
| # manage-settings () { | |
| # get-settings () { set +o; } | |
| # local _saved_settings="$(get-settings)" | |
| # restore-settings () { eval $_saved_settings; } | |
| # return-handler () { trap - RETURN; restore-settings; } | |
| # echo trap return-handler RETURN; | |
| # } | |
| # # within a function in the sourced script | |
| # eval manage-settings | |
| # use-strict () { set -o errexit -o nounset -o pipefail; } | |
| # # and the return handler would restore the settings (maybe) | |
| # Output the args or piped STDIN to STDERR. For args, behaves just like | |
| # echo, so for example, you can use the -n and -e flags. | |
| warn () { | |
| if [[ $# -ne 0 ]]; then | |
| echo >&2 "$@" | |
| else | |
| cat >&2 | |
| fi | |
| } | |
| # returns true if all arguments evaluate to true. | |
| # if no arguments, returns false. | |
| is-true () { | |
| local rc=1 | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| 0|'') rc=1; break;; | |
| *) rc=0; shift;; | |
| esac | |
| done | |
| return $rc | |
| } | |
| # returns true if all arguments evaluate to false. | |
| # if no arguments, returns true | |
| is-false () { | |
| local rc=0 | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| 0|'') shift;; | |
| *) rc=1; break;; | |
| esac | |
| done | |
| return $rc | |
| } | |
| # equivalient to warn if $DEBUG is true. | |
| debug () { | |
| is-false "${DEBUG-}" || warn "$@" | |
| } | |
| # returns true if all arguments are integers. | |
| # if no arguments, returns false | |
| is-int () { | |
| local rc=1 | |
| while [[ $# -gt 0 ]]; do | |
| if ! printf '%i' "$1" &>/dev/null; then | |
| rc=1 | |
| break | |
| else | |
| rc=0 | |
| fi | |
| shift | |
| done | |
| return $rc | |
| } | |
| # Just like perl's croak, report an error from the caller and exit. The exit | |
| # code will be one of the following (in order of precedence) | |
| # - if the first argument is an integer, use that. | |
| # - if the return code of the last command was not 0, use that. | |
| # - 1 | |
| croak () { | |
| local rc=$? | |
| [[ $rc -ne 0 ]] || rc=1 | |
| if is-int "$1"; then | |
| rc="$1" | |
| shift | |
| fi | |
| read line sub file <<< $(caller 1) | |
| warn "ERROR [$file:$line]: $@" | |
| exit $rc | |
| } | |
| # returns true if the first argument matches any of the subsequent arguments | |
| in-list () { | |
| local want="$1" | |
| shift; | |
| for x in "$@"; do | |
| [[ "$x" != "$want" ]] || return 0 | |
| done | |
| return 1 | |
| } | |
| # this version avoids calling external programs, and also supports multi-line | |
| # option definitions | |
| bash-getopt () { | |
| # if there's a -- in the arglist, everything before should be | |
| # passed-thru as an arg to getopt | |
| local -a go_opts=("") # init the array to avoid errors under nounset | |
| if in-list '--' "$@"; then | |
| for (( x=0; x<$#; x++ )); do | |
| if [[ "$1" == '--' ]]; then | |
| shift; break | |
| fi | |
| go_opts[$x]="'$1'" | |
| shift | |
| done | |
| fi | |
| # the remaining options are the ones we want to parse | |
| local -a opts=( "$@" ) | |
| # these vars will collect pieces of info: short option names, long names, | |
| # case statement clauses, and variable initializations | |
| local go_short='' go_long='' go_cases='' go_vars='' | |
| local usage_txt='' help_txt='' | |
| # newline in a variable is useful | |
| local nl=" | |
| " | |
| # if nothing's been piped into this command, there's an error | |
| [[ ! -t 0 ]] || croak "Expected option definitions piped on STDIN" | |
| # read all input into one string. note that this causes read | |
| # to exit with code 1, hence the || true. | |
| read -d '' optdefs || true | |
| # certain characters need to be escaped for the next step | |
| optdefs="${optdefs//|/\|}" | |
| optdefs="${optdefs//=/\=}" | |
| # set the definitions as positional parameters | |
| eval set -- $optdefs | |
| # now process these to build the necessary code for processing the | |
| # actual program options | |
| while [[ $# -gt 0 ]]; do | |
| local raw_spec="$1"; shift | |
| local opt_spec="$raw_spec"; # this one will be edited along the way | |
| # extract the var, if any, this opt's value will be assigned to | |
| local opt_var='' rx_varname='^([_0-9a-zA-Z]+)=' | |
| if [[ "$opt_spec" =~ $rx_varname ]]; then | |
| opt_var="${BASH_REMATCH[1]}" | |
| opt_spec="${opt_spec##*=}" | |
| fi | |
| # extract the "option type indicator" and "unit", if any. (type | |
| # indicator determines if the option takes parameters and if a value | |
| # is required, and unit is shown for help & usage output. | |
| local opt_type='' opt_unit='' rx_typeunit='([:]+)(.*)$' | |
| if [[ "$opt_spec" =~ $rx_typeunit ]]; then | |
| opt_type="${BASH_REMATCH[1]}" | |
| opt_unit="${BASH_REMATCH[2]}" | |
| opt_spec="${opt_spec%%:*}" | |
| fi | |
| # extract short name, long name, and opt type | |
| local opt_short='' opt_long='' | |
| local rx_short='^([0-9a-zA-Z])$' # only short name | |
| local rx_long='^([0-9a-zA-Z][-_0-9a-zA-Z]*[0-9a-zA-Z])$' # only long name | |
| local rx_both='^([0-9a-zA-Z])[|]([0-9a-zA-Z][-_0-9a-zA-Z]*[0-9a-zA-Z])?$' # both | |
| if [[ "$opt_spec" =~ $rx_both ]]; then | |
| opt_short=${BASH_REMATCH[1]} | |
| opt_long=${BASH_REMATCH[2]} | |
| elif [[ "$opt_spec" =~ $rx_long ]]; then | |
| opt_long=${BASH_REMATCH[1]} | |
| elif [[ "$opt_spec" =~ $rx_short ]]; then | |
| opt_short=${BASH_REMATCH[1]} | |
| else | |
| croak "Option specification [$raw_spec] is invalid" | |
| fi | |
| # determine the default value and description | |
| local opt_default='' opt_descr='' | |
| if [[ -n "$opt_type" ]]; then | |
| opt_default="$1" | |
| opt_descr="$2" | |
| shift 2 | |
| else | |
| opt_descr="$1" | |
| shift | |
| fi | |
| # kept this since it's useful for debugging. | |
| debug <<END | |
| Option definition info: | |
| raw_spec="$raw_spec" | |
| opt_var="$opt_var" | |
| opt_spec="$opt_spec" | |
| opt_short="$opt_short" | |
| opt_long="$opt_long" | |
| opt_type="$opt_type" | |
| opt_unit="$opt_unit" | |
| opt_default="$opt_default" | |
| opt_descr="$opt_descr" | |
| END | |
| ### done parsing the option definition. now generate pieces of code | |
| ### for actually processing the caller's options and/or outputting | |
| ### help and usage | |
| # short & long opts gor gnu getopt | |
| go_short+="$opt_short$opt_type" | |
| go_long+="${go_long:+,}$opt_long$opt_type" | |
| # declare and initialize the exported vars | |
| go_vars+="export $opt_var='$opt_default'$nl" | |
| ### build the case clause | |
| local opt_case_match="$opt_short" # the match part of the clause | |
| opt_case_match+="${opt_case_match:+|}$opt_long" | |
| local opt_case_code="" # the code in the case clause | |
| if [[ -z "$opt_type" ]]; then | |
| # boolean flag type | |
| opt_case_code="export $opt_var=1" | |
| else | |
| # value type | |
| # because of a quirk when using a short-opt like "-f=foo" the | |
| # value ends up as "=foo". this is "fixed" by the ${1#=} below | |
| opt_case_code="export $opt_var=\${1#=}; shift" | |
| fi | |
| go_cases+="${go_cases:+ }$opt_case_match) $opt_case_code;;$nl" | |
| ### help/usage text | |
| local opt_help="${opt_short+-$opt_short}" | |
| opt_help="${opt_help- } ${opt_long+--$opt_long}" | |
| [[ -z "$opt_type" ]] || opt_help+="=${opt_unit-VALUE}" | |
| opt_help+="$opt_descr" | |
| [[ -z "$opt_type" ]] || opt_help+=" ['$opt_default']" | |
| help_txt+=" $opt_help$nl" | |
| done | |
| # remove extraneous newlines from generated code | |
| go_vars="${go_vars% | |
| }" | |
| go_cases="${go_cases% | |
| }" | |
| help_txt="${help_txt% | |
| }" | |
| # add options for help output | |
| go_short+="h" | |
| go_long+="${go_long:+,}help" | |
| ### TODO: build up usage & help text | |
| help_text+=" -h --helpdisplay this help text" | |
| help_text="$( column -s '' -t <<< "$help_txt" )" | |
| ### all the necessary pieces are ready! Assemble the getopt | |
| ### code and stuff it in $go_code | |
| read -d '' go_code <<END_GETOPT_CODE || true | |
| $go_vars | |
| _usage () { | |
| cat <<END_USAGE | |
| usage: \$0 [options] | |
| $help_text | |
| END_USAGE | |
| } | |
| local gotopts | |
| if ! gotopts=\$(getopt -o '$go_short' -l '$go_long' ${go_opts[@]} -- \"\${opts[@]}\"); then | |
| rc=\$? | |
| _usage 1>&2 | |
| exit \$rc | |
| fi | |
| eval set -- "\$gotopts" | |
| while [[ \$# -gt 0 ]]; do | |
| local opt="\${1##-}"; opt="\${opt##-}" | |
| shift | |
| case "\$opt" in | |
| $go_cases | |
| h|help) _usage; exit 0;; | |
| '') break;; # end of options | |
| *) croak "Unrecognized option: [\$1]";; | |
| esac | |
| done | |
| # put remaining arguments here. done in two statements like this | |
| # because no other way worked for some reason. | |
| export ARGV=""; [[ \$# -eq 0 ]] || ARGV=( "\$@" ) | |
| END_GETOPT_CODE | |
| debug "$go_code" | |
| eval "$go_code" | |
| } | |
| true # I just felt like putting this here, no real reason. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Cool, I am going to give this a try.