A simple dispatch technique in rust
For two decades or so, maybe more, I used a python script, with a sort of plugin architecture, to interact with my music collection, which I have persisted in managing on disk, thankyouverymuch. (I'm not actually sure when it came into being. The initial import into a mercurial (!) repo was in 2011, but it definitely existed already, as can be told from the fact that parts of it still refer to a library I wrote for interacting with the graphical music players I use, called xmmsalike; xmms's last release was in 2007.) You might use it like this:
if you wanted to play Eyvind Kang's masterpiece BINAH via a command-line player, or like this:
to add the directory containing the album "album" by the artist "artist" to the database, first moving the files to the directory it manages. (Everything presupposes a pretty particular filesystem convention, which isn't really relevant to anything in this post.)
In the Python version, m songs first processes some global options (none given here) and then attempts to load the Python module songs.py (from a specific location, not just anywhere on sys.path), then connects to a SQLite database, then calls the function subsid_main in the loaded module with the remainder of the commandline arguments and various configuration values, including the database connection. The module then parses its own commandline.
Several months ago I got a new computer that simply didn't have any Python 2 version installed,1 and certainly didn't have sqlalchemy installed globally (what, you thought I had a properly structured, modern Python project setup for this? lol), I made the logical decision to rewrite the whole thing in Rust.
One thing I really wanted to preserve, in this rewrite, was the simplicity of the subcommand mechanism. The closest analog, actually dynamically loading a shared library, is not simple in Rust, so not that. A git- or cargo-like approach whereby m songs would actually invoke a separate binary m-songs is also too heavyweight. I don't want to deal with separate compilation of individual binaries or libraries, since it's not as if I expect a thriving independent ecosystem of subcommands for my dinky little thing.
The desiderata here really are like this:
- Separate argument parsing. I don't want a central
clapparser that knows about all the subcommands and what options/arguments they accept, even though I am usingclapand it has good support for that kind of thing. All the main entrypoint should need to know about is its own global options and how to dispatch the remainder to the subcommand. - Adding a new subcommand should require as little boilerplate as possible. Its name shouldn't be repeated in multiple places, I shouldn't need to update a
matchexpression or add an enum variant, or whatever, if at all possible.
I ended up succeeding quite well, I think. The technique I used is not new (I was reminded of it by thinking about how Haskell's servant represents routes, or did when I last looked at it), but it is interesting.
An initial set of clever-seeming ideas all failed because the trait I was using for subcommands isn't dyn-compatible (it includes an async function, because I'm using sqlx for db connectivity, though I suppose I could have made it synchronous and just had the subcommands that needed the database their async stuff internally). dyn-incompatibility means that you can't have one stage that looks up which subcommand implementor you want, returning a Box<dyn Command>, and another that actually executes something; you could have one stage that returns an enum variant, and have the enum also implement Command, but that falls foul of the second desideratum.
The solution ends up looking like this. The main Command trait is:
i.e., commands know their own names, and the run function does not take a self parameter. They are registered and used in the main entrypoint like so:
type Commands = ;
To add a new one, just replace the final Nil with Cons<NewThing, Nil>. (In fact, I have a macro that expands to this, but it's extremely trivial.) Cons and Nil implement, as one might guess, a List trait augmented with its own run method. Directly putting run in the List trait definition is arguably inelegant; fortunately, it isn't really necessary to do it this way:
If one were for some reason concerned about having to traverse a singly-linked list to find the command, it is not difficult to construct a type-level binary search tree, though the simplest way I am aware of does require using nightly, using generic_const_exprs. (You would do something similar to the type-level sorted singly-linked list here.)
I seem to have updated the script to Python 3 at some point. But I'd completely forgotten that I'd done so.