shellcheck icon indicating copy to clipboard operation
shellcheck copied to clipboard

Suggest `getopt` instead of any kind of manual options parsing

Open l0b0 opened this issue 3 years ago • 4 comments

For new checks and feature suggestions

  • [x] https://www.shellcheck.net/ (i.e. the latest commit) currently gives no useful warnings about this
  • [x] I searched through https://github.com/koalaman/shellcheck/issues and didn't find anything related

Here's a snippet that shows the problem:

#!/usr/bin/env bash
if [[ "$1" == "--foo" ]]; then
    …
fi

Here's what shellcheck currently says:

No issues detected!

Here's what I wanted or expected to see:

Use getopt to parse options.

Rationale: It’s tempting to implement basic option handling yourself when there’s only a single option. But there are plenty of pitfalls, and I’d recommend using getopt from the start. For example, here's a bog standard use of getopt AFAICT covering the vast majority of use cases:

#!/usr/bin/env bash

arguments="$(getopt --options='' \
    --longoptions=configuration:,help,include:,verbose --name=foo -- "$@")"
eval set -- "$arguments"
unset arguments

while true
do
    case "$1" in
        --configuration)
            configuration="$2"
            shift 2
            ;;
        --help)
            usage
            exit
            ;;
        --include)
            includes+=("$2")
            shift 2
            ;;
        --verbose)
            verbose=1
            shift
            ;;
        --)
            shift
            break
            ;;
        *)
            printf 'Not implemented: %q\n' "$1" >&2
            exit 1
            ;;
    esac
done

Doing everything this does manually would be a lot of work:

  • Supports both --configuration=foo and --configuration foo call styles.
  • Supports optional -- option/argument separator. If this separator is not part of the original command it's added after all recognised flags, so there's no ambiguity about when to break off the loop.
  • Supports arbitrary option order.
  • Supports arbitrary values. Newlines, spaces, backslashes, you name it. If you escape/quote the inputs properly, they'll be part of the value, with no extra hacks.
  • Supports long options with minimal hacks (--options='' is unfortunately still necessary to specify that I definitely don't want to support any short option names).
  • Prints a useful error message including the program name if parsing fails, which is useful when you're deep in a call stack.
  • If --configuration is specified more than once, the last value is used. This is a common solution to allow a default set of options (for example in a configuration file) which can then be overridden by command-line options. It would be easy to change the example to exit instead, if that's what you want.
  • Accumulates --include values in an array, making them safe to reuse.
  • If we ever hit the default case then there's a flag in the getopt call which is not yet handled by the case, which would be a programmer error. This case is handled gracefully.

l0b0 avatar Sep 25 '22 16:09 l0b0

For example, here's a bog standard use of getopt AFAICT covering the vast majority of use cases:

I might as well leave this here…

Shell-script boilerplate with full getopts support
#
# name: Short description of your program.
#
usage="${0##*/} [-h|--help] [-v|--version] ...files"
version='v1.0.0'

# Parse command-line switches
while [ -n "$1" ]; do case $1 in

	# Print a brief usage summary and exit
	-h|--help|-\?)
		printf 'Usage: %s\n' "$usage"
		exit ;;
	
	# Print a version string and exit
	-v|--version)
		printf '%s\n' "$version"
		exit ;;

	# Unbundle short options
	-[niladic-short-opts]?*)
		tail="${1#??}"
		head=${1%"$tail"}
		shift
		set -- "$head" "-$tail" "$@"
		continue ;;

	# Expand parametric values
	-[monadic-short-opts]?*|--[!=]*=*)
		case $1 in
			--*) tail=${1#*=}; head=${1%%=*} ;;
			*)   tail=${1#??}; head=${1%"$tail"} ;;
		esac
		shift
		set -- "$head" "$tail" "$@"
		continue ;;

	# Add new switch checks here
	--option-name)
		
		break ;;

	# Double-dash: Terminate option parsing
	--)
		shift
		break ;;

	# Invalid option: abort
	--*|-?*)
		>&2 printf '%s: Invalid option: "%s"\n' "${0##*/}" "$1"
		>&2 printf 'Usage: %s\n' "$usage"
		exit 1 ;;

	# Argument not prefixed with a dash
	*) break ;;

esac; shift
done

Here's the snippet version for editors that use TextMate-flavoured snippets (VS Code, Atom, Sublime, TextMate, etc).

Alhadis avatar Nov 24 '22 10:11 Alhadis

Isn't getopt broken everywhere outside Linux? (https://mywiki.wooledge.org/ComplexOptionParsing)

I've actually came across this issue, because I was wondering how to make ShellCheck warn against use of getopt

Jorenar avatar Jun 21 '24 13:06 Jorenar

Using getopt would be really good if the support was fine but there may be cases where getopt isn't supported (most Android), but also case where it is supported but not completely (for example lack of support for long options). So for those that need compatibility it is mostly a problem.

ale5000-git avatar Jun 22 '24 22:06 ale5000-git

Do not use getopt for portability. Except for the GNU version of getopt, it not only cannot handle long options, but also cannot handle arguments that contain spaces. Below is the macOS (FreeBSD) version of getopt in action.

$ ./test.sh -a -o "a b"
-a
-o: a
Not implemented: b

$ ./test.sh -a "a b"
-a
rest arguments:
a
b
#!/usr/bin/env bash

arguments="$(getopt abco: "$@")"
eval set -- "$arguments"
unset arguments

while true
do
    case "$1" in
        -a | -b | c) echo "$1"; shift ;;
        -o) echo "$1: $2"; shift 2 ;;
        --) shift; break ;;
        *)
            printf 'Not implemented: %q\n' "$1" >&2
            exit 1
            ;;
    esac
done

echo "rest arguments:"
for i in "$@"; do
  echo "$i"
done

ko1nksm avatar Aug 23 '24 12:08 ko1nksm