using Programming;

A Blog about some of the intrinsics related to programming and how one can get the best out of various languages.

Getting started with programming and getting absolutely nowhere (Part 6)

It's time to accept arguments

Lesson 5: Now We Begin Our Adventure
Lesson 7: Working out our Step 2 Process

One of the many (very necessary) core features of a program is to take some sort of argument. You want an easy user experience, and you also want to be able to script it. You want to be able to automate your automation, and for that we need to accept arguments. We'll do this through the less appropriate method, but only because it's super easy and allows us to be lazy. We'll allow arguments to our application to be passed in via the CLI, which means we can do some pretty nifty things.

What is an argument?

If you've ever worked with the Windows Command Line Interface (we'll call it the CLI from now on) you hace probably used some command that takes arguments, such as any of the following:

notepad <FILENAME>
ipconfig [/allcompartnets] [/? | /all | ...]

All three of these commands have the same thing in common: they take arguments. It may not have occurred to you (I know it certainly didn't occur immediately to me) but each of these commands is just part of a program that does something. When you type notepad test.txt you're calling the notepad program with the first argument being test.txt. But when you call ipconfig, each command argument is preceeded by a name which starts with a /, such as ipconfig /all, that calls the ipconfig program and passes /all as an argument. From there, the ipconfig program decides how to interpret that; however, for this command the first argument can be something that has sub-properties, like /renew [adapter], which is an argument with an additional property called adapter, completely optional.

This means that ipconfig has no default parameter, like notepad does. This means that the command syntax has meaning, when you notepad you don't want to have to type notepad -file test.txt all the time, you want it to just understand that you're always going to be referencing a file. Usually, we'd like to have the caller specify arguments by name, but sometimes there's a "default" argument, which for us might be the "new list filename".

Now here's a pretty nifty bit of information: when we drag-and-drop a file onto any application, Windows calls that application with the first argument being the filename. So we might want to support automatically filling the newListFilename with whatever the first argument is. Likewise, we might also want to support doing the same with the oldListFilename for the second argument.

Ok, so what are you getting at here?

Right, so I rambled a bit in the previous section, but what I'm getting at is that we're going to prepare our program for CLI calling, and even a drag-and-drop form of calling. We'll do all this in a quick swoop of functions, a module, and a new type.

The first thing we need to know is how to get the arguments. In an F# program you have a main function defined as follows (usually):

let main argv = ...

The argv is what we're going to concern ourselves with. This is basically an array, of an arbitrary length, that contains each argument that was fed to the call of the program. The first argument in the array is the first argument after the program name, so in our cd SomeDirectory example, SomeDirectory will be the first argument in argv. If the argument is surrounded by quotes ("argument because it has spaces") then the quotes are stripped before it's delivered to us. So doing cd "E:\My Directory\" will put E:\My Directory\ as the first string in argv. It's worth noting that argv is always a string array, so if an argument should be an integer we have to parse that out ourselves.

Now I'm not going to go into the details of a full-class argument parser, because we're dealing with simple arguments. We want to define argv.[0] as the newListFilename, we'll define argv.[1] as oldListFilename, and argv.[2] as the sourceSheetName. We'll do this through a type that defines our parameters:

type Parameters = {
    NewListFilename : string
    OldListFilename : string
    SourceSheetName : string

We'll also define a fromDefault function:

let fromDefault defaults args =
    match args with
    | [|newListFilename; oldListFilename; sourceSheetName|] -> Some { defaults with NewListFilename = newListFilename; OldListFilename = oldListFilename; SourceSheetName = sourceSheetName }
    | [|newListFilename; oldListFilename|] -> Some { defaults with NewListFilename = newListFilename; OldListFilename = oldListFilename }
    | [|newListFilename|] -> Some { defaults with NewListFilename = newListFilename }
    | _ -> None

This should be pretty obvious as to what is happening. If args matches an array of three elements, then newListFilename is the first, oldListFilename is the second, and SourceSheetName is the last. If it matches 2, then SourceSheetName is left as default, if it matches 1 then only newListFilename is set, and if it's none then None is returned. This means we can indicate if we even had any CLI arguments, because we may want to show a help page (like cd) or do some default stuff (like ipconfig).

Pull the parameters in

Now ideally we would set the values not-set to None as options, but we're going to do things slightly different, which may not be the most idiomatic method, but it works and it works well. We'll define some default parameters:

let defaults = { NewListFilename = ""; OldListFilename = ""; SourceSheetName = "Process_Spec" }

And then we'll collect them as the first line of our main function:

let parameters =
    match argv |> Parameters.fromDefault defaults with
    | Some p when p.NewListFilename <> "" && p.OldListFilename <> "" -> p
    | Some p when p.NewListFilename <> "" ->
        printf "Enter the old file name to load: "
        { p with OldListFilename = Console.ReadLine() |> String.stripDQuotes }
    | _ ->
        printf "Enter the new file name to load: "
        let temp = { defaults with NewListFilename = Console.ReadLine() |> String.stripDQuotes }
        printf "Enter the old file name to load: "
        { temp with OldListFilename = Console.ReadLine() |> String.stripDQuotes }

So this is pretty self explanatory, and I leave it to you to understand. Our String.stripDQuotes is only present on the Console.ReadLine() calls because dragging-and-dropping a file to our window will actually embed the name with quotes if it has spaces.

We'll then drop the rest of our code (which we write in the previous section) in to our program. The last bit of our main function should be something like the following:

match argv |> Parameters.fromDefault defaults with
| Some p when p.NewListFilename <> "" && p.OldListFilename <> "" ->
    printfn "Done."
| _ ->
    printfn "Done. Press enter to exit."
    Console.ReadLine() |> ignore

We're re-matching the arguments, only to determine how to end processing. If it was called from the CLI with arguments, then we did not prompt the user for input, and as such we will not make them press "enter" to quit. As I'm finished with the lesson for today, I recommend you try to parse arguments with names, that is, /sourceSheetName="Process_Spec", or /sourceSheetName "Process_Spec", such that we could call it from the CLI as step3program.exe /SourceSheetName "Process_Spec" and have it prompt us for the filenames.

The lesson for today was a lot shorter than I had hoped it would be, but it's no less important than any others. I haven't yet decided if I'll be posting a less on Monday as it's a holday here in the U.S., but I have a sneaking suspicion that I'll have on ready by then. Until next time, Brown-out.