unit CommandLineOptions;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, Contnrs;

type

  { TOption }

  TOption = class
    Names: array of String;
    Values: array of String;
    Identifier: Integer;
    HasArg: Boolean;
    Present: Boolean;
    Help: String;
    MultipleValues: Boolean;
    function LongestName: String;
    function Value: String;
    procedure AddValue(AValue: String);
  end;
  TCommandLineOptions = class;


  TOptionReadError = procedure(Sender: TObject; ErrorMessage: String) of object;

  { TCommandLineOptions }

  TCommandLineOptions = class
  private
    FOnError: TOptionReadError;
    FOptions: TObjectList;
    FUnassignedArgs: TStringList;
    FStopReading: Boolean;
    function FindOptionByName(AName: String): TOption;
    function FindOptionByIdentifier(AIdentifier: Integer): TOption;
    procedure DoError(ErrorMessage: String); virtual;
  public
    // first setup options
    procedure SetOptions(ShortOptions: String; LongOptions: array of String);
    procedure AddOption(OptionNames: array of String; HasArg: Boolean = False; Help: String = ''; CanUseMultipleTimes: Boolean = False; Identifier: Integer = -1);
    // read from commandline
    procedure ReadOptions;

    // string based
    function HasOption(AName: String): Boolean;
    function OptionValue(AName:String): String;
    function OptionValues(AName: String): TStrings;

    // tag based
    function HasOption(AIdentifier: Integer): Boolean;
    function OptionValue(AIdentifier: Integer): String;
    function OptionValues(AIdentifier: Integer): TStrings;

    constructor Create;
    destructor Destroy; override;
    function PrintHelp(MaxLineWidth: Integer): TStrings; virtual;
    property OnError: TOptionReadError read FOnError write FOnError;
    property OptionsMalformed: Boolean read FStopReading;
  end;
resourcestring
   ErrUnknownOption        = 'Option unknown:  "%s"';
   ErrArgNeededNotPossible = 'Option "%s" requires an argument but an argument is not possible. (Hint: Use "%s" as last option in group "-%s" or use long option --%s)';
   ErrArgumentNeeded       = 'Option "%s" requires an argument';
   ErrOptionHasNoArgument  = 'Option "%s" does not accept arguments';
   ErrOnlyOneInstance      = 'Option "%s" cannot be used more than once';
   ErrNoEqualsAllowed      = 'Symbol "=" not allowed in argument group "-%s"';
implementation

{ TOption }

function TOption.LongestName: String;
var
  N: String;
begin
  Result := '';
  for N in Names do
  begin
    if Length(N) > Length(Result) then
      Result := N;
  end;
end;

function TOption.Value: String;
begin
  if Length(Values) > 0 then
    Exit(Values[0])
  else
    Result := '';
end;

procedure TOption.AddValue(AValue: String);
begin
  SetLength(Values, Length(Values)+1);
  Values[High(Values)] := AValue;
end;

{ TCommandLineOptions }

function TCommandLineOptions.FindOptionByName(AName: String): TOption;
var
  Opt: TOption;
  N: String;
begin
  Result := Nil;
  for Pointer(Opt) in FOptions do
  begin
    for N in Opt.Names do
      if AName = N then
        Exit(Opt)
  end;
end;

function TCommandLineOptions.FindOptionByIdentifier(AIdentifier: Integer
  ): TOption;
begin

end;

procedure TCommandLineOptions.DoError(ErrorMessage: String);
begin
  FStopReading:=True;
  if Assigned(FOnError) then
    FOnError(Self, ErrorMessage)
  else
    WriteLn(ErrorMessage);
end;

procedure TCommandLineOptions.SetOptions(ShortOptions: String;
  LongOptions: array of String);
var
  L: String;
  S: String;
  HasArg: Boolean;
  P,
  E: PChar;
begin
  P:= PChar(ShortOptions);
  E := P + Length(ShortOptions);

  for L in LongOptions do
  begin
    S := P[0];
    if P+1 < E then
      HasArg:=P[1] = ':';
    Inc(P, 1+Ord(HasArg));
    AddOption([S, L], HasArg);
  end;
end;

procedure TCommandLineOptions.AddOption(OptionNames: array of String;
  HasArg: Boolean; Help: String; CanUseMultipleTimes: Boolean; Identifier: Integer);
var
  Opt: TOption;
  C: Integer;
begin
  Opt := TOption.Create;
  C := Length(OptionNames);
  SetLength(Opt.Names, C);
  for C := Low(OptionNames) to High(OptionNames) do
    Opt.Names[C] := OptionNames[C];
  Opt.HasArg:=HasArg;
  Opt.Identifier:=Identifier;
  Opt.MultipleValues:=CanUseMultipleTimes;
  Opt.Help:=Help;
  FOptions.Add(Opt);
end;

procedure TCommandLineOptions.ReadOptions;
var
  OptIndex: Integer;
          procedure ReadOption(S, G: String; OptionPossible: Boolean);
          var
            Opt: TOption;
            Arg: String;
            HasEq: Integer = 0;
          begin
            HasEq := Pos('=', S);
            if HasEq > 0 then
            begin
               Arg := Copy(S, HasEq+1, Length(S));
               S := Copy(S,1, HasEq-1);
            end;

            Opt := FindOptionByName(S);
            if Opt = Nil then
            begin
              DoError(Format(ErrUnknownOption, [S]));
              Exit;
            end;
            if Opt.HasArg and not OptionPossible then
            begin
              DoError(Format(ErrArgNeededNotPossible, [S, S, G, Opt.LongestName]));
              Exit;
            end;

            if Opt.HasArg then
            begin
              if (OptIndex = Paramcount) and (HasEq = 0) then
              begin
                DoError(Format(ErrArgumentNeeded, [S]));
                Exit;
              end;
              if Opt.Present and not Opt.MultipleValues then
              begin
                DoError(Format(ErrOnlyOneInstance, [S]));
                Exit;
              end;

              // Verify???

              if HasEq = 0 then
              begin
                Arg := ParamStr(OptIndex+1);
                Inc(OptIndex);
              end;
              Opt.AddValue(Arg);
            end
            else if HasEq > 0 then
            begin
              DoError(Format(ErrOptionHasNoArgument, [S]));
            end;

            Opt.Present:=True;
          end;

          procedure ReadSingleOptions(S: String);
          var
            I: Integer;
          begin
            if S[1] = '-' then // its a long option with 2 dashes : --option
              ReadOption(Copy(S,2,Length(S)), '', True)
            else // short options put together :  -abcdefg
            begin
              if Pos('=', S) > 0 then
              begin
                DoError(Format(ErrNoEqualsAllowed,[S]));
                Exit;
              end;
              for I := 1 to Length(S) do
                ReadOption(S[I], S, I = Length(S));
            end;
          end;
var
  RawOpt: String;
begin
  OptIndex:=0;
  while OptIndex < Paramcount do
  begin
    if FStopReading then
      Exit;
    Inc(OptIndex);
    RawOpt := ParamStr(OptIndex);
    if (RawOpt[1] = '-') and (RawOpt <> '-') then // '-' is treated as an unassigned arg.
      ReadSingleOptions(Copy(RawOpt,2,Length(RawOpt)))
    else
      FUnassignedArgs.Add(RawOpt);
  end;
end;

function TCommandLineOptions.HasOption(AName: String): Boolean;
var
  Opt: TOption;
begin
  Result := True;
  Opt := FindOptionByName(AName);
  if (Opt = nil) or not(Opt.Present) then
    Result := False;
end;

function TCommandLineOptions.OptionValue(AName: String): String;
var
  Opt: TOption;
  S: String;
begin
  Opt := FindOptionByName(AName);
  Result := Opt.Value;
end;

function TCommandLineOptions.OptionValues(AName: String): TStrings;
var
  Opt: TOption;
  S: String;
begin
  Opt := FindOptionByName(AName);
  Result := TStringList.Create;
  if Opt = nil then
    Exit;
  for S in Opt.Values do
    Result.Add(S);
end;

function TCommandLineOptions.HasOption(AIdentifier: Integer): Boolean;
var
  Opt: TOption;
begin
  Result := False;
  Opt := FindOptionByIdentifier(AIdentifier);
  if Opt = nil then
    Exit;

  Result := Opt.Present;
end;

function TCommandLineOptions.OptionValue(AIdentifier: Integer): String;
var
  Opt: TOption;
begin
  Result := '';
  Opt := FindOptionByIdentifier(AIdentifier);
  if Opt = nil then
    Exit;

  Result := Opt.Value;
end;

function TCommandLineOptions.OptionValues(AIdentifier: Integer): TStrings;
var
  Opt: TOption;
  Tmp: String;
begin
  Result := TStringList.Create;
  Opt := FindOptionByIdentifier(AIdentifier);
  if Opt = nil then
    Exit;

  for Tmp in Opt.Values do
    Result.Add(Tmp);
end;

constructor TCommandLineOptions.Create;
begin
  FOptions := TObjectList.create(True);
  FUnassignedArgs := TStringList.Create;
end;

destructor TCommandLineOptions.Destroy;
begin
  FOptions.Clear;
  FOptions.Free;
  FUnassignedArgs.Free;
  inherited Destroy;
end;

function TCommandLineOptions.PrintHelp(MaxLineWidth: Integer): TStrings;
var
  Padding: array [0..255] of char;
   function Space(Orig: String; LengthNeeded: Integer; Before: Boolean = False): String;
   begin
     if not Before then
       Result := Orig+Copy(Padding,0,LengthNeeded-Length(Orig))
     else
       Result := Copy(Padding,0,LengthNeeded-Length(Orig))+Orig;
   end;

var
  Opt: TOption;
  Tmp: String;
  Line: String;
  LinePart: String;
  I, J: Integer;
  S,L,D: TStringList; // short opt, long opt, description
  SL, LL: String; // short line, long line
  SLL, LLL: Integer; //short line length, long line length
  LineSize: Integer;
  Gap: Integer;
begin
  FillChar(Padding, 256, ' ');
  S := TStringList.Create;
  L := TStringList.Create;
  D := TStringList.Create;
  Result := TStringList.Create;
  for I := 0 to FOptions.Count-1 do
  begin
    SL := '';
    LL := '';
    Line := '';
    Opt := TOption(FOptions.Items[I]);
    for Tmp in Opt.Names do
      if Length(Tmp) = 1 then
        SL := SL + ' -' + Tmp
      else
        LL := LL + ' --' + Tmp;

    S.Add(SL);
    L.Add(LL);
    D.Add(Opt.Help);
  end;
  SLL := 0;
  LLL := 0;

  for Tmp in S do
    if Length(Tmp) > SLL then
      SLL := Length(Tmp);

  for Tmp in L do
    if Length(Tmp) > LLL then
      LLL := Length(Tmp);


  for I := 0 to S.Count-1 do
  begin
    LinePart := '';
    SL := Space(S[I], SLL);
    LL := Space(L[I], LLL);
    Line := SL + ' ' + LL + '    '+ D[I];
    if Length(Line) > MaxLineWidth then
    begin
      LineSize:=MaxLineWidth;
      Gap := 0;
      repeat
        J := LineSize;
        //if J > Length(Line) then J := Length(Line);
        while (J > 0){ and (Length(Line) > 0)} do
        begin
          if (Line[J] = ' ') or (J = 1) then
          begin
            LinePart := Copy(Line, 1, J);
            LinePart := Space(LinePart, Length(LinePart)+Gap, True);
            Delete(Line,1,J);
            Result.Add(LinePart);
            break;
          end;
          Dec(J);
        end;
        Gap := SLL+1+LLL+4;
        LineSize := MaxLineWidth-(Gap);
      until Length(Line) = 0;
    end
    else
      Result.Add(Line);
  end;
  S.Free;
  L.Free;
  D.Free;

end;

end.