Generic Command Line Parser for Delphi 10.3.x

You know the feeling. You need a specific input from your command line, but you can’t find something lightweight that does the job. Hence, the FDC.CommandLine was born.

You can use – / or + as a switch, and use : or = as a flag for parameters.
/option:parameter

You can use single and double quotes to be able to parse parameters that contain spaces and quotes.
/quoteoftheday:”This is a ‘quoted’ parameter”

It also supports lists of parameters.
/inputfiles:(file1.txt , “file with space.txt”)

Take a look at the TestFDCCommandLine.dpr for more examples of how it works.

Breaking down the classes gives you three basic elements

The TOptionParser contains TOption elements, and each TOption can contain one or more TParam elements. TOption<T> gives us generic typed option parameters TParam<T>, and it supports strings, ints, floats and enumerated types.

By default, any “anonymous” option that you parse and look up by name will be of the type TOption<String> and hence will contains strings as parameters.

You can however create typed instances in your parser descendant that allow you to use the actual names of enumerated type elements.

type
  TMyColors = (Red, White, Blue);

type
  TMyParser = class(TOptionParser)
    Color: TOption<TMyColors>;
    constructor Create;
  end;

constructor TMyParser.Create;
begin
  Inherited;
  Color := Declare<TMyColors>('Color', White);
end;

var
  MyParser: TMyParser;
begin
  MyParser.Create;
  MyParser.Parse('/color:blue');

In this example, White is set as the default color, but since the options string contains the color blue, you can refer to MyParser.Color.Value and see that it indeed equals TMyColors.Blue after parsing.

Note that DefaultValue can also be changed after declaration, and it will be used if parsing a parameter value fails to resolve to one of the enumerated values of the type.

Finally, we have the TCommandLine descendant from TOptionParser which basically just concatenates all the ParamStr values into one string and runs the parser on it.

My little command line project feature ballooned a bit, but I am satisfied with the result and I hope it can be of help to others as well.

unit FDC.CommandLine;
/// <summary>
/// Written by Lars Fosdal, August 2019, Delphi 10.3.1
/// Your license to use and modify follows the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license rules.
/// https://creativecommons.org/licenses/by-sa/4.0/
/// </summary>
interface
uses
System.Classes, System.StrUtils, System.SysUtils, System.SyncObjs,
System.Generics.Defaults, System.Generics.Collections, System.RTTI, System.TypInfo;
type
/// <summary> DebugOut handler </summary>
TCmdDebugOutHandler = reference to procedure(const aMsg: string);
/// <summary> Setting the current DebugOut handler </summary>
procedure SetCmdDebugOutHandler(const aHandler: TCmdDebugOutHandler);
/// <summary> Output a debug string in this unit </summary>
procedure CmdDebugOut(const aMsg: string);
type
/// <summary> A value assigned to an option </summary>
TParam = class abstract
protected
function GetAsString: string; virtual; abstract;
procedure SetAsString(const aValue: string); virtual; abstract;
function Debug: string;
public
constructor Create; virtual; abstract;
property AsString: string read GetAsString write SetAsString;
end;
const
/// <summary> The characters recognized as an option switch </summary>
CmdSwitch: TSysCharSet = ['-', '+', '/'];
/// <summary> The characters allowed in an option name </summary>
CmdName: TSysCharSet = ['a'..'z', 'A'..'Z', '0'..'9', '_', '?', '&', '#', '%'];
/// <summary> The characters recognized as whitespace </summary>
CmdSpace: TSysCharSet = [' ', ^I];
/// <summary> The characters allowed as a flag after an option </summary>
CmdFlag: TSysCharSet = ['+', '-', '=', ':'];
/// <summary> The characters recognized as list separators </summary>
CmdList: TSysCharSet = [';', ','];
type
/// <summary> An option may contain multiple parameters
/// <code>/option1=" a parameter " /option2=(param1, param2, "param 3") /option3+ --option4-</code>
/// </summary>
TOption = class abstract
private
FName: string;
FSwitch: string;
FFlag: string;
FGiven: Boolean;
FOrder: Integer;
protected
function Add: TParam; virtual; abstract;
function Duplicate: string; virtual; abstract;
public
constructor Create; virtual; abstract;
/// <summary> Clears values of options, but does not remove them - unlike Clear </summary>
procedure ClearValues; virtual; abstract;
/// <summary> Option debug info </summary>
function Debug: string; virtual; abstract;
/// <summary> First parameter value </summary>
function AsString: string; virtual; abstract;
/// <summary> All parameter values as string </summary>
function AsStringList: TArray<string>; virtual; abstract;
/// <summary> Option name as in /option </summary>
property Name: string read FName write FName;
/// <summary> Leading switch(es) </summary>
property Switch: string read FSwitch write FSwitch;
/// <summary> Trailing flag(s) </summary>
property Flag: string read FFlag write FFlag;
/// <summary> True if found in parsing </summary>
property Given: Boolean read FGiven write FGiven;
/// <summary> Order in parsed string </summary>
property Order: Integer read FOrder write FOrder;
end;
/// <summary> Generic parameter value of type T</summary>
TParam<T> = class(TParam)
private
FValue: T;
FParent: TOption;
protected
function GetAsString: string; override;
procedure SetAsString(const aValue: string); override;
property Parent: TOption read FParent write FParent;
public
constructor Create; override;
property Value: T read FValue write FValue;
end;
/// <summary><para>Option containing parameters of type T. The default type of unknown options is String.</para>
/// <para>This can be extended by adding predefined typed options before parsing.</para>
/// <para>Supports string, int, float, and enumerated types.</para></summary>
TOption<T> = class(TOption)
type
TParamList = class(TObjectList<TParam<T>>);
private
FParams: TParamList;
FDefaultValue: T;
function GetValue: T;
function GetValueList: TArray<T>;
function Analyzed: string;
protected
function Add: TParam; override;
function Duplicate: string; override;
property Params: TParamList read FParams;
public
constructor Create; override;
destructor Destroy; override;
procedure ClearValues; override;
function Debug: string; override;
function AsString: string; override;
function AsStringList: TArray<string>; override;
function MultiParams: Boolean;
property DefaultValue: T read FDefaultValue write FDefaultValue;
property Value: T read GetValue;
property ValueList: TArray<T> read GetValueList;
end;
/// <summary> The actual parser that collects options and their parameters </summary>
TOptionParser = class(TObjectDictionary<string, TOption>)
type
TOptionComparer = class(TComparer<TOption>)
function Compare(const Left, Right: TOption): Integer; override;
end;
public
constructor Create; reintroduce; virtual;
procedure ClearValues;
function Declare<T>(const aName: String; const aDefaultValue: T): TOption<T>; overload;
procedure Declare(const aName: String; aOption: TOption); overload;
function Duplicate: string;
function DuplicateExcluding(const aNames: TArray<String>): String;
procedure Parse(aOptions: string);
function Given(const aName: string): boolean;
function Option<T>(const aName: string): TOption<T>;
function AsString(const aName: string; const aDefault: string = ''): string;
function AsStringList(const aName: string): TArray<string>;
procedure Debug(const Strings: TStrings);
end;
/// <summary> Concatenates all ParamStr entries and parses the command line </summary>
TCommandLine = class(TOptionParser)
private
FCmdLn: string;
public
constructor Create; override;
property CmdLn: string read FCmdLn;
end;
/// <summary> Singleton of the current commandline ready parsed </summary>
function CommandLine: TCommandLine;
/// <summary> Lookup a specific option in the current commandline </summary>
function CommandLineStr(const aName:String; const aDefault:String = ''): string;
implementation
var
CallDebugOut: TCmdDebugOutHandler;
procedure CmdDebugOut(const aMsg: string);
begin
CallDebugOut(aMsg);
end;
procedure SetCmdDebugOutHandler(const aHandler: TCmdDebugOutHandler);
begin
CallDebugOut := aHandler;
end;
procedure NoOutput(const aMsg: string);
begin
// sends aMsg nowhere 😛
end;
{ TParam }
function TParam.Debug: string;
begin
Result := AsString;
if Pos(' ', Result) > 0
then begin
if Pos('"', Result)> 0
then Result := '''' + Result + ''''
else Result := '"' + Result + '"';
end;
end;
{ TParam<T> }
constructor TParam<T>.Create;
begin
inherited;
FValue := Default(T);
end;
function TParam<T>.GetAsString: string;
var
TV: TValue;
begin
TV := TValue.From<T>(FValue);
Result := TV.AsString;
end;
procedure TParam<T>.SetAsString(const aValue: string);
var
TV: TValue;
begin
TV := TValue.From<T>(Default(T));
try
case TV.Kind of
tkEnumeration: TV := TValue.FromOrdinal(TypeInfo(T), GetEnumValue(TypeInfo(T), aValue));
tkInteger: TV := TValue.From<Integer>(StrToInt(aValue));
tkInt64: TV := TValue.From<Int64>(StrToInt(aValue));
tkFloat: TV := TValue.From<Extended>(StrToFloat(aValue));
else TV := TValue.From<String>(aValue);
end;
FValue := TV.AsType<T>;
except
on E:Exception
do begin
CmdDebugOut(Parent.Debug + ': "' + aValue + '" -> ' + E.Message);
FValue := Default(T);
end;
end;
end;
{ TOptionParser.TOptionComparer }
function TOptionParser.TOptionComparer.Compare(const Left, Right: TOption): Integer;
begin
if (Left.Order < Right.Order)
then Result := -1
else if (Left.Order > Right.Order)
then Result := 1
else Result := 0;
end;
{ TOption<T> }
procedure TOption<T>.ClearValues;
begin
FGiven := False;
FParams.Clear;
end;
constructor TOption<T>.Create;
begin
inherited;
FName := '';
FSwitch := '';
FFlag := '';
FGiven := False;
FOrder := 0;
FDefaultValue := Default(T);
FParams := TParamList.Create;
end;
destructor TOption<T>.Destroy;
begin
FParams.Free;
inherited;
end;
function TOption<T>.Add: TParam;
begin
var p:= TParam<T>.Create;
p.Parent := Self;
Result := p;
FParams.Add(p);
end;
function TOption<T>.Duplicate: string;
var
ParamValues, Semi: string;
begin
ParamValues := '';
if Params.Count = 1
then ParamValues := Params[0].Debug
else begin
semi := '';
for var p in params
do begin
ParamValues := ParamValues + Semi + p.Debug;
semi := ';'
end;
if ParamValues <> ''
then ParamValues := '(' + ParamValues + ')';
end;
Result := Switch + Name + Flag + ParamValues;
end;
function TOption<T>.Analyzed: string;
var
ParamValues, Semi: string;
begin
ParamValues := '';
if Params.Count = 1
then ParamValues := Params[0].Debug
else begin
semi := '';
for var p in params
do begin
ParamValues := ParamValues + Semi + p.Debug;
semi := ';'
end;
if ParamValues <> ''
then ParamValues := '(' + ParamValues + ')';
end;
Result := Format('Switch[%s] Option[%s] Flag[%s] Values[%s]',
[Switch, Name, Flag, ParamValues]);
end;
function TOption<T>.Debug: string;
begin
Result := Order.ToString + ' ' + Analyzed;
end;
function TOption<T>.AsString: string;
begin
if Params.Count > 0
then Result := Params[0].AsString
else Result := '';
end;
function TOption<T>.AsStringList: TArray<string>;
begin
Result := [];
if Params.Count > 0
then begin
for var p in Params
do Result := Result + [p.AsString];
end;
end;
function TOption<T>.GetValue: T;
begin
if Params.Count > 0
then Result := Params[0].Value
else Result := DefaultValue;
end;
function TOption<T>.GetValueList: TArray<T>;
begin
SetLength(Result, Params.Count);
for var ix := 0 to Params.Count - 1
do Result[ix] := Params[ix].Value;
end;
function TOption<T>.MultiParams: Boolean;
begin
Result := Params.Count > 1;
end;
{ TOptionParser }
function TOptionParser.AsString(const aName: string; const aDefault: string = ''): string;
var
Option: TOption;
begin
if TryGetValue(LowerCase(aName), Option)
then Result := Option.AsString
else Result := '';
if Result = ''
then Result := aDefault;
end;
function TOptionParser.AsStringList(const aName: string): TArray<string>;
var
Option: TOption;
begin
if TryGetValue(LowerCase(aName), Option)
then Result := Option.AsStringList
else Result := [];
end;
constructor TOptionParser.Create;
begin
Inherited Create([doOwnsValues], 19);
end;
procedure TOptionParser.ClearValues;
begin
for var Option in Values
do Option.ClearValues;
end;
procedure TOptionParser.Debug(const Strings: TStrings);
begin
for var Option in Values
do Strings.Add(Option.Debug);
end;
procedure TOptionParser.Declare(const aName: String; aOption: TOption);
begin
aOption.Name := aName;
Add(LowerCase(aName), aOption);
end;
function TOptionParser.Declare<T>(const aName: String; const aDefaultValue: T): TOption<T>;
begin
Result := TOption<T>.Create;
Result.DefaultValue := aDefaultValue;
Declare(aName, Result);
end;
function TOptionParser.Duplicate: string;
begin
Result := DuplicateExcluding([]);
end;
function TOptionParser.DuplicateExcluding(const aNames: TArray<String>): String;
var
List: TList<TOption>;
opt: TOption;
comp: TOptionComparer;
begin
List := TList<TOption>.Create;
try
for opt in self.Values
do begin
for var name in aNames
do if CompareText(name, opt.Name) = 0
then Continue;
List.Add(opt);
end;
comp := TOptionComparer.Create;
try
List.Sort(comp);
finally
comp.Free;
end;
Result := '';
for opt in List
do Result := Result + ' ' + opt.Duplicate;
finally
List.Free;
end;
end;
function TOptionParser.Given(const aName: string): boolean;
var
Option: TOption;
begin
if TryGetValue(LowerCase(aName), Option)
then Result := Option.Given
else Result := False;
end;
function TOptionParser.Option<T>(const aName: string): TOption<T>;
var
Option: TOption;
begin
if TryGetValue(LowerCase(aName), Option)
then Result := Option as TOption<T>
else Result := nil;
end;
/// Without arguments: option /option -option --option
///
/// With flag: option- /option+ -option+ --option-
///
/// With single value: option:value /option : value -option= 0 --option: 2019.04.01
///
/// With quoted values - any character can be allowed in a quoted string until a matching end quote is found
/// option:"This is 'a' string" /option: 'This is "a" string'
///
/// With a list of values: option:("some filename" ; 'another file name')
procedure TOptionParser.Parse(aOptions: string);
function Get(chSet: TSysCharSet): String;
begin
Result := '';
var p := 1;
while (p <= Length(aOptions)) and CharInSet(aOptions[p], chSet)
do begin
var ch := aOptions[p];
if (ch <> ' ')
then Result := Result + ch;
Inc(p);
end;
Delete(aOptions, 1, p - 1);
end;
function Grab(aQuote: Char): String;
begin
if aOptions[1] = aQuote
then begin
Delete(aOptions, 1,1);
var p := Pos(aQuote, aOptions);
if p <= 0
then p := Length(aOptions) + 1;
Result := Copy(aOptions, 1, p - 1);
Delete(aOptions, 1, p);
end;
end;
function GrabUntil(chSet: TSysCharSet): String;
begin
Result := '';
var p := 1;
var ch := #0;
var l := Length(aOptions);
if l > 0
then ch := aOptions[1];
while (p <= l) and not CharInSet(ch, chSet)
do begin
Result := Result + ch;
Inc(p);
if p <= l
then ch := aOptions[p];
end;
Delete(aOptions, 1, Length(Result));
end;
function Peek: string;
begin
if aOptions <> ''
then Result := aOptions[1]
else Result := '';
end;
var
switch, name, flag, value, LastOptions: string;
Option: TOption;
begin
ClearValues;
aOptions := Trim(aOptions);
while aOptions <> ''
do begin
LastOptions := aOptions;
{$ifdef debug}
// CmdDebugOut('Parse:' + aOptions);
{$endif}
switch := Get(CmdSwitch);
if Peek = ' '
then Get(CmdSpace);
name := Get(CmdName);
flag := '';
if Peek <> ' '
then flag := Get(CmdFlag)
else Get(CmdSpace);
if (Peek = ':') or (Peek = '=')
then flag := Get(CmdFlag);
Get(CmdSpace);
if (name <> '') and not TryGetValue(LowerCase(name), Option)
then begin
Option := Declare<String>(name, '');
Option.Order := Self.Count;
end;
Option.Switch := switch;
Option.Flag := flag;
Option.Given := True;
if (Pos(':', flag) + Pos('=', Flag)) > 0 // a parameter has been declared
then begin
if Peek = '('
then begin // array of arguments
Get(['('] + CmdSpace);
repeat
if Peek = '"'
then value := Grab('"')
else if Peek = ''''
then value := Grab('''')
else value := Trim(GrabUntil([')'] + CmdList));
if Value <> ''
then begin
var param := Option.Add;
param.AsString := value;
end;
Get(CmdList + CmdSpace);
until (Peek = ')') or (Peek = '');
Get([')'] + CmdSpace);
end // single argument
else begin
if Peek = '"'
then value := Grab('"')
else if Peek = ''''
then value := Grab('''')
else value := Trim(GrabUntil(CmdSpace));
if Value <> ''
then begin
var param := Option.Add;
param.AsString := value;
end;
end;
end;
Get(CmdSpace);
if LastOptions = aOptions // nothing was consumed,
then Delete(aOptions, 1, 1); // so deal with unexpected characters that cause a loop
aOptions := Trim(aOptions);
end;
end;
{ TCommandLine }
constructor TCommandLine.Create;
begin
inherited;
FCmdLn := '';
for var ix := 1 to ParamCount
do FCmdLn := FCmdLn + ParamStr(ix) + ' ';
Parse(CmdLn);
{$ifdef debug}
CmdDebugOut('Org: ' + CmdLn);
CmdDebugOut('Dup: ' + Duplicate);
var SL := TStringList.Create;
try
Debug(SL);
CmdDebugOut(SL.Text);
finally
SL.Free;
end;
{$endif}
end;
var
CommandLineSection : TCriticalSection;
CurrentCommandLine : TCommandLine;
function CommandLine:TCommandLine;
begin
CommandLineSection.Acquire;
try
if not Assigned(CurrentCommandLine)
then CurrentCommandLine := TCommandLine.Create;
Result := CurrentCommandLine;
finally
CommandLineSection.Release;
end;
end;
function CommandLineStr(const aName:String; const aDefault:String):String;
begin
Result := CommandLine.AsString(aName, aDefault);
end;
initialization
SetCmdDebugOutHandler(NoOutput);
CommandLineSection := TCriticalSection.Create;
CurrentCommandLine := nil;
finalization
CommandLineSection.Acquire;
try
CurrentCommandLine.Free;
finally
CommandLineSection.Release;
CommandLineSection.Free;
end;
end.
view raw FDC.CommandLine.pas hosted with ❤ by GitHub
program TestFDCCommandLine;
{$APPTYPE CONSOLE}
{$R *.res}
uses
System.Classes,
System.SysUtils,
System.TypInfo,
FDC.CommandLine in 'FDC.CommandLine.pas';
type
TExtendedParserExample = class(TOptionParser)
public
AnInt: TOption<Integer>;
ADouble: TOption<Double>;
AString: TOption<String>;
ABool: TOption<Boolean>;
AEnum: TOption<TTypeKind>;
constructor Create; override;
end;
{ TExtendedParserExample }
constructor TExtendedParserExample.Create;
begin
inherited;
AnInt := Declare<Integer>('anint', 1337);
ADouble := Declare<Double>('adouble', 3.14);
AString := Declare<String>('astring', 'hi there');
ABool := Declare<Boolean>('abool', true);
AEnum := Declare<TTypeKind>('Aenum', tkClass);
end;
begin
FDC.CommandLine.SetCmdDebugOutHandler(
procedure(const aMsg: string)
begin
Writeln(aMsg);
end);
try
try
var SL := TStringList.Create;
var P := TOptionParser.Create;
var TestLines := [
' /strings="String A, StringB,StringC, String D and E"',
' /strings=(String A, StringB,StringC, String D and E)',
' /test+ -grab- --hold -files:( "A B C"; ''D E F'' 😉 -wunder:(abc def; ghi jkl) - crap="gnarf',
' GlobalLocationId=1 ClientIdentifier=This_Is_A_Test'
];
for var Test in TestLines
do begin
P.Clear; // will clear previously parsed options
Writeln('Parse(''',Test,''')');
P.Parse(Test);
SL.Clear;
P.Debug(SL);
Writeln(SL.Text);
Writeln('AsString(''strings''): ' + P.AsString('strings'));
Writeln;
Writeln('ClientIdentifier = ', P.AsString('ClientIDentifier'));
Writeln;
end;
var CL := TCommandLine.Create;
Writeln('Original Cmd: ', CL.CmdLn);
Writeln('CommandLine: ', CL.Duplicate);
SL.Clear;
CL.Debug(SL);
Writeln(SL.Text);
Writeln;
var EP := TExtendedParserExample.Create;
Writeln('Before: ', EP.AnInt.Value, ' ', EP.ADouble.Value, ' ', EP.AString.Value, ' ', EP.ABool.Value, ' ',
GetEnumName(TypeInfo(TTypeKind), Ord(EP.AEnum.Value)));
var Test := 'anint:14 adouble=' + Format('%f',[1.23]) + ' astring="ho ho ho" abool=false aenum=tkProcedure';
Writeln('Parse(''',Test,''')');
EP.Parse(Test);
Writeln('After: ', EP.AnInt.Value, ' ', EP.ADouble.Value, ' ', EP.AString.Value, ' ', EP.ABool.Value, ' ',
GetEnumName(TypeInfo(TTypeKind), Ord(EP.AEnum.Value)));
except
on E: Exception do
Writeln(E.ClassName, ': ', E.Message);
end;
finally
Writeln;
Write('Press Enter: ');
Readln;
end;
end.

For those of you that are on older version of Delphi than 10.3, I apologise for the inline variable declarations, but they are so addictive! It shouldn’t be too hard to rewrite it to use oldschool local variables, though.

You can leave your comments here, or at Delphi Praxis.

Leave a Reply

Please log in using one of these methods to post your comment:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.