////////////////////////////////////////////////////////////////////////////////
//                                                                            //
// Copyright (c) 2020 Alexey Kuryakin kouriakine@mail.ru under LGPL license.  //
//                                                                            //
// Purpose:                                                                   //
//  Command line arguments parser.                                            //
//                                                                            //
// History:                                                                   //
//  20201123 - 1st release                                                    //
////////////////////////////////////////////////////////////////////////////////

unit _cmdargs; // Command line arguments parser.

{$IFDEF FPC}{$mode objfpc}{$ENDIF}{$H+}

interface

uses SysUtils,Classes;

////////////////////////////////////////////////////////////////////////////////
// CmdArgs is command arguments parser.
// Usage is like:
//  CmdArgs.Count           = total argument count = ParamCount+1
//  CmdArgs.Strings[0]      = executable file name as ParamStr(0)
//  CmdArgs.Strings[i]      = command line argiment with number i
//  CmdArgs.IsOption(S)     = true if S looks like option, i.e. as -opt or /opt
//  CmdArgs.ListOptVal      = Get/set list of options which (must) have a values
//                            List is semicolon separated, like '-dns;-obj;-set'
//                            Value (val) of option (opt) must be set as one of:
//                            1) as -opt=val or 2) as -opt val    In second case
//                            the val exclude from list of positional parameters
//  CmdArgs.NeedOptVal(Opt) = true if Opt is in ListOptVal and has no equal sign
//                            use it to check if option Opt need to have a value
//  CmdArgs.NumOptions      = number of arguments which is looks like an options
//  CmdArgs.HasOption(Opt)  = true if command line has option with name = Opt
//  CmdArgs.HasOption(n)    = true if command line has option with number = n
//  CmdArgs.GetOption(n)    = return command line option with given number = n
//  CmdArgs.GetOptionValue(Opt) = get option value; Opt must be in ListOptVal
//  CmdArgs.NumParams       = number of parameters, exclude options+their values
//  CmdArgs.HasParam(n)     = true if command line has parameter with number = n
//  CmdArgs.GetParam(n)     = return positional parameter number n, skip options
// Notes:
//  1) Options looks like -opt (or /opt in Windows) is allowed
//  2) Options may have values 1) as -opt val or 2) as -opt=val
//  3) Options which need values must be included in ListOptVal
//  4) Params is any arguments which is not looks like options
//  5) Order of Params is important, it's positional arguments
//  6) Order of Options don't matter (-a -b are same as -b -a)
//  7) Special option -- uses to terminate options; after that
//     any argument interprets as param even if started with -
//  8) Option names is not case sensitive, so -x equals to -X
// Examples:
//  CmdArgs.ListOptVal:='--test;--demo'; // Options need values
//  for i:=0 to CmdArgs.Count-1 do ShowMessage(CmdArgs.Strings[i]);
//  if CmdArgs.HasOption('--test') then
//  ShowMessage('--test has value '+CmdArgs.GetOptionValue('--test'));
//  if CmdArgs.HasOption('--demo') then
//  ShowMessage('--demo has value '+CmdArgs.GetOptionValue('--demo'));
//  This code will recognize for example:
//   test.exe --demo Demo1 --test=Test1 Param1 Param2
//   test.exe --demo=Demo2 Param1 --test Test2 Param2
//  See also ReportCmdArgs.
////////////////////////////////////////////////////////////////////////////////

type
 TCmdArgs = class(TStringList)
  private
   FExeFilePath : String;
   FExeFileName : String;
   FExeBaseName : String;
   FExeFileDir  : String;
   FExeFileExt  : String;
   FListOptVal  : String;
   FListOptPat  : String;
   FListOpt     : TStringList;
   FListPar     : TStringList;
  protected
   procedure   Update;
   procedure   Parser;
   procedure   SetListOptVal(const aList:String);
   function    GetPattern(const S:String):String;
   function    HasEqualsSign(const S:String):Boolean;
  public
   constructor Create;
   destructor  Destroy; override;
   procedure   AfterConstruction; override;
   procedure   BeforeDestruction; override;
  public
   property    ExeFilePath : String read FExeFilePath;          // Executable file path as dir\base.ext
   property    ExeFileName : String read FExeFileName;          // Executable file base.ext
   property    ExeBaseName : String read FExeBaseName;          // Executable file basename
   property    ExeFileDir  : String read FExeFileDir;           // Executable file dir
   property    ExeFileExt  : String read FExeFileExt;           // Executable file ext
  public
   function    IsOption(const S:String):Boolean;                // String S looks like Option (-opt,/opt) ?
   function    ExtractName(const S:String):String;              // Extract Name  from expression Name=Value
   function    ExtractValue(const S:String):String;             // Extract Value from expression Name=Value
  public
   property    ListOptVal  : String read FListOptVal            // List (semicolon separated) of Options
                                   write SetListOptVal;         // which need a value as '-dns;-obj;-set'
   function    NeedOptVal(const Opt:String):Boolean;            // Option need a value (is in ListOptVal)?
  public
   function    NumOptions:Integer;                              // Number of options (started with -)
   function    GetOption(n:Integer):String;                     // Get name of option with number n
   function    HasOption(n:Integer):Boolean; overload;          // Check option number n exists
   function    HasOption(const Opt:String):Boolean; overload;   // Check option name Opt exists
   function    GetOptionValue(n:Integer):String; overload;         // Get option value by index
   function    GetOptionValue(const Opt:String):String; overload;  // -Opt=Value or -Opt Value
   function    HasOptionValue(n:Integer):Boolean; overload;        // Check option number n has a value
   function    HasOptionValue(const Opt:String):Boolean; overload; // Check option name Opt has a value
  public
   function    NumParams:Integer;                               // Number of parameters (not options)
   function    HasParam(n:Integer):Boolean;                     // Check positional parameter, skip options
   function    GetParam(n:Integer):String;                      // Get positional parameter, skip options
  end;

function CmdArgs:TCmdArgs;      // The only one command line parser

function ReportCmdArgs:String;  // Return a text of CmdArgs report

// Check compile system is Windows
{$IFDEF WIN32} const IsWindows32 = true; {$ELSE} const IsWindows32 = false; {$ENDIF}
{$IFDEF WIN64} const IsWindows64 = true; {$ELSE} const IsWindows64 = false; {$ENDIF}
const IsWindows = IsWindows32 or IsWindows64;

implementation

var TheCmdArgs:TCmdArgs=nil;

function CmdArgs:TCmdArgs;
begin
 if (TheCmdArgs=nil) then TheCmdArgs:=TCmdArgs.Create;
 Result:=TheCmdArgs;
end;

constructor TCmdArgs.Create;
begin
 inherited Create;
 FListOpt:=TStringList.Create;
 FListPar:=TStringList.Create;
 Update;
end;

destructor TCmdArgs.Destroy;
begin
 Clear;
 FListOpt.Clear;
 FListPar.Clear;
 FExeFilePath:='';
 FExeFileName:='';
 FExeBaseName:='';
 FExeFileDir:='';
 FExeFileExt:='';
 FListOptVal:='';
 FListOptPat:='';
 FListOpt.Free;
 FListPar.Free;
 inherited Destroy;
end;

procedure TCmdArgs.AfterConstruction;
begin
 inherited AfterConstruction;
 if (TheCmdArgs=nil) then TheCmdArgs:=Self;
end;

procedure TCmdArgs.BeforeDestruction;
begin
 if (TheCmdArgs=Self) then TheCmdArgs:=nil;
 inherited BeforeDestruction;
end;

procedure TCmdArgs.Update;
var i:Integer;
begin
 Clear;
 FListOpt.Clear;
 FListPar.Clear;
 for i:=0 to ParamCount do Add(ParamStr(i));
 FExeFilePath:=ExpandFileName(ParamStr(0));
 FExeFileName:=ExtractFileName(ExeFilePath);
 FExeBaseName:=ChangeFileExt(ExeFileName,'');
 FExeFileDir:=ExtractFileDir(ExeFilePath);
 FExeFileExt:=ExtractFileExt(ExeFilePath);
 Parser;
end;

procedure TCmdArgs.Parser;
var i:Integer; arg,opt,val,par:String; optflag,optval:Boolean;
begin
 if (Count>1) then begin
  i:=1; optflag:=true;
  while (i<Count) do begin
   arg:=Strings[i];
   if optflag and (arg='--') then optflag:=false else
   if optflag and IsOption(arg) then begin
    optval:=NeedOptVal(arg);
    if optval then inc(i);
    opt:=ExtractName(arg);
    val:=ExtractValue(arg);
    if optval then begin
     if (i<Count) // option as -opt value in ListOptVal
     then FListOpt.Add(Format('%s=%s',[opt,Strings[i]]))
     else FListOpt.Add(opt); // incomplete tail -opt
    end else begin
     // option as -opt or -opt=value
     if HasEqualsSign(arg) and NeedOptVal(opt)
     then FListOpt.Add(Format('%s=%s',[opt,val]))
     else FListOpt.Add(opt);
    end;
   end else begin
    par:=arg;
    FListPar.Add(par);
   end;
   inc(i);
  end;
 end;
end;

procedure TCmdArgs.SetListOptVal(const aList:String);
begin
 FListOptVal:=Trim(aList);
 FListOptPat:=GetPattern(FListOptVal);
 Update;
end;

function TCmdArgs.GetPattern(const S:String):String;
begin
 if (S='') then Result:=S else Result:=';'+LowerCase(Trim(S))+';';
end;

function TCmdArgs.HasEqualsSign(const S:String):Boolean;
begin
 Result:=(Pos('=',S)>0);
end;

function TCmdArgs.IsOption(const S:String):Boolean;
begin
 Result:=false;
 if (S='') then Exit;
 if (S[1]='-') then Result:=true else
 if (S[1]='/') then Result:=IsWindows;
end;

function TCmdArgs.ExtractName(const S:String):String;
var i:Integer;
begin
 Result:=S;
 i:=Pos('=',S);
 if (i=0) then Exit;
 Result:=Copy(S,1,i-1);
end;

function TCmdArgs.ExtractValue(const S:String):String;
var i:Integer;
begin
 Result:='';
 i:=Pos('=',S);
 if (i=0) then Exit;
 Result:=Copy(S,i+1,Length(S)-i);
end;

function TCmdArgs.NeedOptVal(const Opt:String):Boolean;
begin
 Result:=false;
 if (ListOptVal='') then Exit;
 if not IsOption(Opt) then Exit;
 if HasEqualsSign(Opt) then Exit;
 if (Pos(GetPattern(Opt),FListOptPat)=0) then Exit;
 Result:=true;
end;

function TCmdArgs.NumOptions:Integer;
begin
 Result:=FListOpt.Count;
end;

function TCmdArgs.GetOption(n:Integer):String;
begin
 Result:='';
 if (n>=1) and (n<=FListOpt.Count)
 then Result:=ExtractName(FListOpt.Strings[n-1]);
end;

function TCmdArgs.HasOption(n:Integer):Boolean;
begin
 Result:=(n>=1) and (n<=FListOpt.Count);
end;

function TCmdArgs.HasOption(const Opt:String):Boolean;
begin
 Result:=false;
 if (Opt='') then Exit;
 if not IsOption(Opt) then Exit;
 if (FListOpt.IndexOf(Opt)<0) and (FListOpt.IndexOfName(Opt)<0) then Exit;
 Result:=true;
end;

function TCmdArgs.GetOptionValue(n:Integer):String;
begin
 Result:='';
 if (n>=1) and (n<=FListOpt.Count)
 then Result:=ExtractValue(FListOpt.Strings[n-1]);
end;

function TCmdArgs.GetOptionValue(const Opt:String):String;
var i:Integer;
begin
 Result:='';
 if (Opt='') then Exit;
 if not IsOption(Opt) then Exit;
 i:=FListOpt.IndexOfName(Opt);
 if (i>=0) then Result:=ExtractValue(FListOpt.Strings[i]);
end;

function TCmdArgs.HasOptionValue(n:Integer):Boolean;
begin
 Result:=false;
 if (n>=1) and (n<=FListOpt.Count)
 then Result:=HasEqualsSign(FListOpt.Strings[n-1]);
end;

function TCmdArgs.HasOptionValue(const Opt:String):Boolean;
var i:Integer;
begin
 Result:=false;
 if (Opt='') then Exit;
 if not IsOption(Opt) then Exit;
 i:=FListOpt.IndexOfName(Opt);
 if (i>=0) then Result:=HasEqualsSign(FListOpt.Strings[i]);
end;

function TCmdArgs.NumParams:Integer;
begin
 Result:=FListPar.Count;
end;

function TCmdArgs.GetParam(n:Integer):String;
begin
 Result:='';
 if (n>=1) and (n<=FListPar.Count)
 then Result:=FListPar.Strings[n-1]; 
end;

function TCmdArgs.HasParam(n:Integer):Boolean;
begin
 Result:=(n>=1) and (n<=FListPar.Count);
end;

function ReportCmdArgs:String;
var i:Integer; List:TStringList; Line,Opt:String;
 procedure Add(const s:String); begin List.Add(s); end;
begin
 Result:='';
 List:=TStringList.Create;
 try
  Add('Report of CmdArgs:');
  Add(Format('ParamCount = %d',[CmdArgs.Count-1]));
  Add(Format('NumOptions = %d',[CmdArgs.NumOptions]));
  Add(Format('NumParams  = %d',[CmdArgs.NumParams]));
  for i:=1 to CmdArgs.NumOptions do
  if CmdArgs.HasOption(i) then begin
   Opt:=CmdArgs.GetOption(i);
   Line:=Format('Options[%d] = %s',[i,Opt]);
   if CmdArgs.HasOptionValue(Opt) then Line:=Line+Format(' %s',[CmdArgs.GetOptionValue(Opt)]);
   Add(Line);
  end;
  for i:=1 to CmdArgs.NumParams do
  if CmdArgs.HasParam(i) then begin
   Add(Format('Params[%d] = %s',[i,CmdArgs.GetParam(i)]));
  end;
  Result:=List.Text;
 finally
  List.Free;
 end;
end;

initialization

 CmdArgs;

end.
