Tuesday, October 18, 2011

Harnessing the PowerShell command line parser in .net

One of the annoying niggles about the .net framework is the lack of an intrinsic command line parser -- yes, there's Mono.Options or the F# power-pack, but they're not always just there to hand. PowerShell is there on Win7 and up, and is likely to be there on machines with older OS versions in a suitably technical environment, So if you just want to whip up a simple command line tool, and you're not writing it in PowerShell, why not at least borrow its features?

First, we want to get the argument list as provided -- for this we have to get the semi-raw line via Win32 (semi-raw as the shell will already have processed escapes -- in PowerShell that means that backticks, semi-colons and double-dashes are significant):

module CommandLineParser =
module private NativeMethods =
// Retrieves the command line as processed by the shell
[<DllImport("kernel32.dll", CharSet = CharSet.Auto)>]
extern System.IntPtr GetCommandLine();
// Remove the executable name (first token) from the command-line
let ExtractCommandLineArgumentString() =
let ptr = NativeMethods.GetCommandLine();
let commandLine = Marshal.PtrToStringAuto(ptr);
let result = PSParser.Tokenize(commandLine)
commandLine.Substring( (fst result).[0].EndColumn )
view raw gistfile1.fs hosted with ❤ by GitHub

This uses the PowerShell tokenizer to strip out the executable name. We also want to be able to describe what the command line parameters are like, and other usage text

type Switch = {
Name : string;
Usage : string }
type Value = {
Name : string;
Required : bool;
Type : Type;
Usage : string }
type Argument =
| Switch of Switch
| Value of Value
type Application = {
Name : string;
Synopsis : string;
Description : seq<string>;
Arguments : seq<Argument> }
view raw gistfile1.fs hosted with ❤ by GitHub

Then we can build a PowerShell function that takes the parameters we've defined, and just places them in a hash : this is the meat of the exercise

// Build a simple PowerShell function with the command line we want
let GenerateFunction (app : Application) =
let preamble = String.Join("\r\n", [| "<#";
".SYNOPSIS ";
app.Synopsis;
" ";
".DESCRIPTION \r\n"; |]) + String.Join("\r\n\r\n", Seq.toArray app.Description) + "\r\n\r\n"
let usage = String.Join("\r\n",
app.Arguments
|> Seq.map(fun a -> match a with
| Switch {Name=name; Usage=usage} -> (name, usage)
| Value {Name=name; Usage=usage} -> (name, usage))
|> Seq.map (fun (name, usage) -> String.Format(System.Globalization.CultureInfo.InvariantCulture,
".PARAMETER {0}\r\n\r\n{1}\r\n",
name, usage))
|> Seq.toArray) + "\r\n"
let decl = String.Join("\r\n", [| "#>" ;
"function Parse-CommandLine";
"{";
" param ( " |]) + "\r\n"
let prefix (isMandatory : bool) =
if isMandatory then
"[Parameter(Mandatory=$true)]"
else
String.Empty
let parameters = String.Join(",\r\n",
app.Arguments
|> Seq.map(fun a -> match a with
| Switch {Name=name} -> (name, "switch", false)
| Value {Name=name; Type=``type``; Required=required} -> (name, ``type``.FullName, required))
|> Seq.map (fun (name, ``type``, required) -> String.Format(System.Globalization.CultureInfo.InvariantCulture,
" {2} [{1}] ${0}",
name, ``type``,
prefix required))
|> Seq.toArray) + "\r\n"
let body = String.Join("\r\n", [| ")";
" @{" |]) + "\r\n"
let assignment = String.Join(";\r\n",
app.Arguments
|> Seq.map(fun a -> match a with
| Switch {Name=name} -> name
| Value {Name=name} -> name)
|> Seq.map (fun name -> String.Format(System.Globalization.CultureInfo.InvariantCulture,
" {0}=${0}", name))
|> Seq.toArray) + "\r\n"
let coda = String.Join("\r\n", [| "'$args' = $args" ;
" }";
"}" |])
preamble + usage + decl + parameters + body + assignment + coda
view raw gistfile1.fs hosted with ❤ by GitHub

For producing a usage description, we can ask PowerShell for one, flatten it to a sequence of strings, and then strip out the unwanted PowerShell-isms

let Runspace = new RunspaceInvoke()
let RunScript (script: string) =
Runspace.Invoke(script)
let Usage (app : Application) =
let text = (GenerateFunction app) +
"\r\n$a = Get-Help Parse-CommandLine -full\r\n" +
"$a.Name = \"" + app.Name + "\"\r\n" + // replace the fake name
"$a.details.name = \"" + app.Name + "\"\r\n" + // replace the fake name
"$a.Syntax.SyntaxItem.Name = \"" + app.Name + "\"\r\n" + // replace the fake name
"$a | Out-String -Stream"
let result = RunScript(text)
(result |> Seq.map (fun p -> p.BaseObject)).OfType<string>()
|> Seq.takeWhile (fun l -> l.Trim() <> "<CommonParameters>")
|> Seq.map (fun l -> l.Replace("[<CommonParameters>]", String.Empty))
|> Seq.filter (fun l -> not <| l.Trim().StartsWith("Accept pipeline input?", StringComparison.Ordinal))
|> Seq.filter (fun l -> not <| l.Trim().StartsWith("Accept wildcard characters?", StringComparison.Ordinal))
|> Seq.filter (fun l -> not <| l.Trim().StartsWith("Default value", StringComparison.Ordinal))
|> Seq.iter (fun x -> Console.WriteLine("{0}", x))
view raw gistfile1.fs hosted with ❤ by GitHub

Finally, we can parse a supplied command-line, writing a failure reason and usage info if things fail


let Parse (app : Application) (commandline : string) =
let text = (GenerateFunction app) +
"\r\nParse-CommandLine " + commandline + "\r\n"
try
let r = RunScript(text)
r.[0].BaseObject :?> System.Collections.Hashtable
with
| :? ParameterBindingException as fault ->
Console.WriteLine("{0}", fault.Message)
Usage app
reraise()
view raw gistfile1.fs hosted with ❤ by GitHub

A simple example driver would be

[<EntryPoint>]
let main a =
let arg = Value {Name = "ValueArg"; Required = false; Type = typeof<string>; Usage = "This is the value usage"}
let arg2 = Value {Name = "NextArg"; Required = false; Type = typeof<int>; Usage = "This is the 2nd value usage"}
let switch = Switch {Name = "SwitchArg"; Usage = "This is the switch usage" }
let here = Assembly.GetExecutingAssembly().Location
let h = (new FileInfo(here)).Name
let app = { Name = h; Synopsis = "This is the synopsis" ;
Description = [| "this command does stuff"; "wonderful stuff" |] ;
Arguments = [| arg; arg2; switch |] }
let hash = CommandLineParser.Parse app <| CommandLineParser.ExtractCommandLineArgumentString()
hash.Keys |> Seq.cast
|> Seq.iter (fun k -> printfn "%A -> %A" k (hash.Item(k)))
0
view raw gistfile1.fs hosted with ❤ by GitHub

There are some quirks -- Mandatory positional parameters and arbitrary nameless options don't mix : the current example program if given a command line "-Val hello -N 23 -S a b c" will yield


"$args" -> [|"a"; "b"; "c"|]
"ValueArg" -> "hello"
"SwitchArg" -> True
"NextArg" -> 23

Make NextArg mandatory, and instead it goes

A positional parameter cannot be found that accepts argument 'a'.

NAME
    ConsoleApplication1.exe

SYNOPSIS
    This is the synopsis

SYNTAX
    ConsoleApplication1.exe [[-ValueArg] <string>] [-NextArg] <int32> [-SwitchArg]...

So, perhaps not industrial strength; but suited to gentle use when there's nothing else to hand.

No comments :