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 ) |
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> } |
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 |
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)) |
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() |
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 |
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 :
Post a Comment