Command line applications in Clojure.
This post assumes clojure and leiningen are already installed.
The code for this post is available here.
Creating the project
First, we create a new project using the default leiningen template.
lein new default cli-test
Now, cd
into cli-test
and lets edit project.clj
. I'm using vim but any editor is fine.
vim project.clj
We want to add a dependency to the clojure tools.cli library. There isn't much of a description to quote for the tools.cli library but here it is anyway.
Tools for working with command line arguments.
At the time of this post the current version of tools.cli is 0.3.1
. Add [org.clojure/tools.cli "0.3.1"]
to the project :dependencies
.
While we have project.clj
open lets also tell it where the entry point for our command line app is going to be. Add a :main
key and give it the value cli-test.core
.
We should now have a project file that looks something like the one below.
Now, lets edit src/cli_test/core.clj
.
vim src/cli_test/core.clj
We will make use of clojure AOT compilation here and add a :gen-class
directive.
An optional :gen-class directive can be used in the ns declaration to generate a named class corresponding to a namespace. (:gen-class ...), when supplied, defaults to :name corresponding to the ns name, :main true, :impl-ns same as ns, and :init-impl-ns true. All options of gen-class are supported. more
The usage of :gen-class
tells clojure to AOT compile a java class. For clarity we explictly use :main true
to :gen-class
a class with a main
method, this is not required as :main
defaults to true. We also need to hook up this main
function, so, we define -main
.
The reason we use -main
is because of convention. The generated main
function is effectively a stub that will look for a function of the same name but prefixed with a -
.
If we wanted to use a different prefix we could change our usage of the :gen-class
directive, like so.
Our -main
function simply prints back the arguments we pass it.
If we do a lein compile :all
we can then try to run our application. We can do so using the leiningen run command lein run hello mark
or we can generate a jar file using lein uberjar
and then execute that jar using java -jar target/cli-test-0.1.0-SNAPSHOT-standalone.jar hello mark
.
Generally I prefer to use lein run
but in some situations I've found it easier to manage more complex command line arguments by using java -jar
.
A sprinkling of tools.cli
Now that we have a simple command line application we can start to make use of tools.cli.
We will create the structure for a command line application that represents a HTTP server and supports the following commands.
start ;; Start a HTTP server on port 8080 by default
stop ;; Stops a HTTP server on port 8080 by default
The application will print help when it receives an unexpected command or when it receives the -h option.
First, lets define a vector of options our application accepts. We will accept -p
or –port
as options to the start
and stop
commands to specify the port.
Note that we can also define a :default
value, a :parse-fn
that will parse the option value and a :validate
function that will validate it.
Now, lets define a function that can give us some option context help.
We need to modify the namespace declaration to require clojure.string
and clojure.tools.cli
.
We can now make use of the parse-opts
function from clojure.tools.cli
. As the name suggests, this function will parse the command line arguments against the cli-options
we defined earlier. It will return a map containing:
:options ;; The parsed options
:arguments ;; The non option based arguments
:summary ;; A summary of the options
:errors ;; Any errors
To demonstrate this lets change our -main
function to call parse-opts
and print the results.When we do a lein run
we should see the following printed.
If we do a lein run start -p 8089
we should see the print changes.
Now we can see our arguments and options are getting parsed lets do something useful and wire up the help.
Note. We also defined an exit
function that simply displays a message and sets the exit code.
If we do another lein run
or a lein run -h
we should get our awesome help printed out.
Lets see if we can handle our start
command now.
All we needed to do was add a case statement to handle our start
command. We also pulled out the :port
value from our options
map.
We can handle the stop
command in the same way.
A little bit of tweaking
While this is quite nice, I think it would be better if we define a map of our handlers.
Then in our -main
function we can just lookup the handler.
All we do here is turn the first argument, in our case start
or stop
, into a keyword using the keyword function.
The doc string for keyword
says
Returns a Keyword with the given namespace and name. Do not use : in the keyword strings, it will be added automatically.
Once we have the keyword we can lookup its value from the handlers
map.
The unhappy path
So far we have only really handled the happy path. What happpens if we try to run an unknown command, for example lein run unknown -p 1234
.
At the moment this will throw a NullPointerException
as we don't have a handler defined for the unknown
command.
Lets fix that.
If we cant find a handler we print the help.
There is also another issue, even though we defined parse-fn
and validation
functions for our -p
or –port
option we aren't doing anthing to handle the errors they generate. We have to explicitly handle these.
If we run lein run start -p fred
we will get the output.
Starting HTTP server on port 8080
Notice that the default value is used because our -p
option was invalid. It would be better it we notified the user of this.
We add an extra test to our cond
. If there are any errors then we exit with the appropriate error message. We also add an error-msg
function that wraps our errors.
If we run lein run start -p fred
this time we get the output.
Error while parsing option "-p fred": java.lang.NumberFormatException: For input string: "fred"
If we run lein run start -p 100000
then we get the output.
The were errors processing the command line arguments
Failed to validate "-p 100000": Must be a number between 0 and 65536