ash: a hybrid between getopts and 'sh -c'

10 points by knl


hc

that’s a cool approach

slight downside: name collision with busybox’s shell

dutc

Thank you for sharing this. Congratulations on releasing a new tool!

I am generally skeptical of how argument parsing is done pretty much across all languages, but I admit that the simplistic style of argument parsing is genuinely useful in many circumstances. This is the general approach taking by getopt (C,) zparseopts (Zsh,) argparse (Python,) flag (Go) which may appear to have different APIs but, in my view, are largely interchangeable. Personally, I have totally abandoned zparseopts in my Shell scripting, but I still use argparse regularly. (I think flag is pretty terrible, though, and is the cause of design flaws like Docker -v.)

It occurs to me that the CLI interface is often the primary way in which a user interacts with a tool. I don’t think I’ve done more than the briefest skim of the socat source code—the only exposure I have to the tool is through using it via its CLI interface and reading its documentation. It is only the extraördinary thoroughness of possible endpoints (“address types”) that brings me back to this tool on a consistent basis; it’s CLI interface departs from expectations and, in doing so, introduces ambiguities that are frustrating to resolve. (e.g., socat - exec:'ssh remote socat - exec:zsh,pty,rawer' fails on launch because the embedded : is ambiguous and even if you fix this, you still have to contend with the ,pty,rawer being ambiguous. A standard -- interface would not have such ambiguities.) Indeed, there are many tools that have poor CLI interface design, yet are the only available, comparable way to solve a problem, so we have no real choice. (Which CLI interface more pleasant to use? gstreamer or ffmpeg?) It is also true that in many cases we reach for the first command-line tool we can find to solve a problem, and we struggle through whatever interface it provides, hoping to never revisit that problem or that tool again. In my experience, it is not that common that a CLI interface might be so unergonomic that I abandon a tool completely; however, it is definitely the case that a CLI interface may have limitations that lead me to try solving (a smaller version of) the problem myself, and it is definitely the case that an unergonomic or unpleasant CLI interface pushes me away from frequent, non-^r use of a tool.

In other words, I think it’s quite important to think through the design of your CLI interface to make it pleasant and useful for end-users. This is something that is worth paying attention to, and you may readily find that doing a good job requires departing from getopt/zparseopts/argparse/flags-style libraries! (Similarly, I think the -h/--help documentation they automatically generate is a place where we can dramatically improve user experience at low effort; however, most of these libraries let you get pretty close with a “big block of extra text to print to the screen” escape hatch. This, coupled with good error messages, usually works well enough.)

It occurs to me that argument parsing has two main goals (though most tools only achieve the former):

While the former may look like a parsing problem, thus we might think that a more generalised version of the getopt/zparseopts/argparse/flags-style might involve writing a parser of some form, I believe that’s still a simplification. I think a lot of common CLI interfaces actually form context-sensitive grammars, so typical parsing approaches are insufficient. From this perspective, you can see why I have abandoned zparseopts in lieu of a classical while (( # )); do case "${1}" in *) ... ;; esac; shift; done loop (as you can see in other recent posts.) The latter is the tersest way of encoding the necessary state machine. This is also why I group together almost all argument parser libraries in one category. Despite their superficial differences, none of them allow you to directly encode the state machine that performs this transformation.

Setting that aside, reviewing this specific tool, I have the following feedback:

† Basically, the value proposition of this tool is that rather than writing…

some-command/wrapper() {
  local -ar usage=(
    'some-command [--flag] [-h|--help]'
    'does some command'
    '--flag some modality'
    '--help print this message and exit
  )
  local -a targets=()
  local -i flag=0
  while (( # )); do
    case "${1}" in
      --flag) flag=1 ;;
      -h|--help) for ln ( "${(@)usage}" ) <<< "${ln}" ; exit 0;;
      --) break ;;
      *)  for ln ( "${(@)usage}" ) <<< "${ln}" >&2 ; exit 1 ;;
    esac
    shift
  done
  (( # )) && shift
  (( # )) && targets+=( "${@}" ) || targets=( default )
  if (( flag )); then
      exec some-command --mode "${(@)targets}"
  else
      exec some-command --no-mode "${(@)targets}"
  fi
}

… you write (not strictly equivalent to the above)…

# XXX: this doesn't actually work, because I believe
#      the flags are passed as `*string` but `eq`
#      requires a `string`…
# XXX: also, `| join` interpolation here won't do
#      correct quoting
some-command/wrapper() {
  local -ar flags=( '--flag true "sets mode or no mode"' )
  local -r template=<<-EOF
    {{ if eq .flag "true" -}}
        some-command --mode {{ if gt (.args | len) 0 }}{{ join .args " " }}{{ else }}default{{ end }}
    {{- else -}}
        some-command --no-mode {{ if gt (.args | len) 0 }}{{ join .args " " }}{{ else }}default{{ end }}
    {{- end }}
EOF
  exec ash "${(@)flags}" "${template}" "${@}"
}

Obviously, the former encoding (using just a Shell function) is much more generalisable. It is similarly obvious that there are formulations where the latter encoding (using ash) will be significantly shorter at no loss of reuqired generality. However, just as the ash encoding needs to have certain key details fixed; so can the Shell function encoding have certain inconveniences addressed. We may be able to cover the gap between the amount of boilerplate necessary in these cases, while still benefitting from the significantly greater generality of the Shell approach. Additionally, when we consider the evolution of this encoding over time, we could argue that the Shell function encoding is less discontinuous: as the complexity of our requirements for argument handling increases, the complexity of the code increases minimally. In the case of ash, as the complexity of our argument handling increases, the complexity of the template may increase substantially, up to a point where we are no longer able to encode what we want, and then we have to start over from scratch and write the Shell function. From this perspective, you can imagine how the moment we step even slightly beyond the simplest example where the terseness of ash clearly wins, we may decide to just ditch the tool for the standard approach. Thus, you can see the value proposition is quite muddled and somewhat weak.

Again, congratulations on releasing a new tool!

cadey

This is really cool. I love it.

kbd

This seems good but I don’t totally get it. Could anyone give more examples?