{
  Copyright 1998-2026 PasDoc developers.

  This file is part of "PasDoc".

  "PasDoc" is free software; you can redistribute it and/or modify
  it under the terms of the GNU General Public License as published by
  the Free Software Foundation; either version 2 of the License, or
  (at your option) any later version.

  "PasDoc" is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with "PasDoc"; if not, write to the Free Software
  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA

  ----------------------------------------------------------------------------
}

{ @abstract(Command line option parsing.)
  @author(Johannes Berg <johannes@sipsolutions.de>)

  To use this unit, create an object of @link(TOptionParser) and add options to
  it, each option descends from @link(TOption).
  Then, call your object's @link(TOptionParser.ParseOptions)
  method and options are parsed.
  After parsing, examine your option objects. }
unit PasDoc_OptionParser;

{$I pasdoc_defines.inc}

interface
uses
  Classes;

const
  { default short option character used }
  DefShortOptionChar = '-';
  { default long option string used }
  DefLongOptionString = '--';
  { Marks "include config file" option }
  OptionFileChar = '@';
  { Special substitution that, if found inside a config file, will be replaced
    with actual path of the file }
  CfgMacroCfgPath = '$CFG_PATH';
  { Indentation of option's name from the start of console line }
  OptionIndent = '  ';
  { Separator between option's name and explanation }
  OptionSep = '  ';
  { Width of console }
  ConsoleWidth = 80;

type
  TOptionParser = class;
  { @abstract(Base class for options.)
    This class implements all the basic functionality and provides
    abstract methods for the @link(TOptionParser) class to call, which are
    overridden by descendants.
    It also provides function to write the explanation.
  }
  TOption = class
  protected
    FShort: char;
    FLong: string;
    FShortSens: boolean;
    FLongSens: boolean;
    FExplanation: string;
    FWasSpecified: boolean;
    FParser: TOptionParser;
    function ParseOption(const AWords: TStrings): boolean; virtual; abstract;
  public
    { Create a new Option.
      Set AShort to #0 in order to have no short option.
      Technically you can set ALong to '' to have no long option,
      but in practive *every* option should have long form.
      Don't override this in descendants (this always simply calls
      CreateEx). Override only CreateEx. }
    constructor Create(const AShort:char; const ALong: string);

    constructor CreateEx(const AShort:char; const ALong: string;
      const AShortCaseSensitive, ALongCaseSensitive: boolean); virtual;

    { returns the width of the string "-s, @--long-option" where s is the short option.
      Removes non-existant options (longoption = '' or shortoption = #0) }
    function GetOptionWidth: Integer;
    { writes the wrapped explanation including option format,
      AOptWidth determines how much it is indented & wrapped }
    procedure WriteExplanation(const AOptWidth: Integer);
    { Short form of the option --- single character --- if #0 then not used }
    property ShortForm: char read FShort write FShort;
    { long form of the option --- string --- if empty, then not used }
    property LongForm: string read FLong write FLong;
    { specified whether the short form should be case sensitive or not }
    property ShortCaseSensitive: boolean read FShortSens write FShortSens;
    { specifies whether the long form should be case sensitive or not }
    property LongCaseSensitive: boolean read FLongSens write FLongSens;
    { signifies if the option was specified at least once }
    property WasSpecified: boolean read FWasSpecified;
    { explanation for the option, see also @link(WriteExplanation) }
    property Explanation: string read FExplanation write FExplanation;
  end;

  { @abstract(Boolean option, "on" if was specified.)

    Note: This cannot handle something like @--option=false .
    Whether the option is @link(TurnedOn) simply depends on whether it was
    specified. }
  TBoolOption = class(TOption)
  protected
    function ParseOption(const AWords: TStrings): boolean; override;
  public
    property TurnedOn: boolean read FWasSpecified;
  end;

  { @abstract(Base class for all options that have values.)
    These options that take one or more values
    of the form @--option=value or @--option value etc }
  TValueOption = class(TOption)
  protected
    function CheckValue(const AString: String): boolean; virtual; abstract;
    function ParseOption(const AWords: TStrings): boolean; override;
  end;

  { @abstract(Option that accepts additional Integer as a value.) }
  TIntegerOption = class(TValueOption)
  protected
    FValue: Integer;
    function CheckValue(const AString: String): boolean; override;
  public
    property Value: Integer read FValue write FValue;
  end;

  { @abstract(Option that accepts additional string as a value.) }
  TStringOption = class(TValueOption)
  protected
    FValue: String;
    function CheckValue(const AString: String): boolean; override;
  public
    property Value: String read FValue write FValue;
  end;

  { @abstract(stringlist option)
    accepts multiple strings and collates them
    even if the option itself is specified more than one time }
  TStringOptionList = class(TValueOption)
  protected
    FValues: TStringList;
    function CheckValue(const AString: String): Boolean; override;
  public
    property Values: TStringList read FValues;
    constructor CreateEx(const AShort: Char; const ALong: String;
      const AShortCaseSensitive, ALongCaseSensitive: Boolean); override;
    destructor Destroy; override;
  end;

  { @abstract(pathlist option)
    accepts multiple strings paths and collates them
    even if the option itself is specified more than one time.
    Paths in a single option can be separated by the
    DirectorySeparator }
  TPathListOption = class(TStringOptionList)
    function CheckValue(const AString: String): Boolean; override;
  end;

  { Option that allows to define a set.

    Names for the set elements are defined by @link(PossibleValues),
    which must be a comma-separated list of possible values.

    Set @link(Values) to be the default set.
    After parsing, @link(Values) contains what the user has provided.

    Set elements in @link(Values) may optionally be specified with a ? suffix.
    This is useful for toggleable visibility members for --visible-members option. }
  TSetOption = class(TValueOption)
  protected
    FPossibleValues, FValues: TStringList;
    function GetPossibleValues: string;
    procedure SetPossibleValues(const Value: string);
    function CheckValue(const AString: String): Boolean; override;
    function GetValues: string;
    procedure SetValues(const Value: string);
  public
    property PossibleValues: string read GetPossibleValues write SetPossibleValues;
    constructor CreateEx(const AShort: Char; const ALong: String;
      const AShortCaseSensitive, ALongCaseSensitive: Boolean); override;
    destructor Destroy; override;

    { Was given value specified (with or without ? suffix). }
    function HasValue(const AValue: string): boolean;

    { Returns true if the value was specified with a ? suffix.
      Example use-case is "--visible-members=protected?,public" which means that
      protected members should be included but hidden by default.
      When this is @true, then HasValue(AValue) always returns @true as well. }
    function HasToggleValue(const AValue: string): boolean;

    property Values: string read GetValues write SetValues;
  end;

  { @abstract(OptionParser --- instantiate one of these for commandline parsing)
    This class is the main parsing class, although a lot of parsing is handled
    by @link(TOption) and its descendants instead. }
  TOptionParser = class
  protected
    FParams: TStringList;
    FOptions: TList;
    FLeftList: TStringList;
    FShortOptionChar: Char;
    FLongOptionString: string;
    FIncludeFileOptionName: string;
    FIncludeFileOptionExpl: string;
    function GetOption(const AIndex: Integer): TOption;
    function GetOptionsCount: Integer;
    function GetOptionByLongName(const AName: string): TOption;
    function GetOptionByShortname(const AName: char): TOption;
  public
    { Create without any options --- this will parse the current command line }
    constructor Create; virtual;
    { Create with parameters to be used instead of command line }
    constructor CreateParams(const AParams: TStrings); virtual;
    { destroy the option parser object and all associated @link(TOption) objects }
    destructor Destroy; override;
    { Add a @link(TOption) descendant to be included in parsing the command line }
    function AddOption(const AOption: TOption): TOption;
    { Parse the specified command line, see also @link(Create) }
    procedure ParseOptions;
    { output explanations for all options to stdout, will nicely format the
      output and wrap explanations }
    procedure WriteExplanations;
    { This StringList contains all the items from the command line that could
      not be parsed. Includes options that didn't accept their value and
      non-options like filenames specified on the command line }
    property LeftList: TStringList read FLeftList;
    { The number of option objects that were added to this parser }
    property OptionsCount: Integer read GetOptionsCount;
    { retrieve an option by index --- you can use this and @link(OptionsCount)
      to iterate through the options that this parser owns }
    property Options[const AIndex: Integer]: TOption read GetOption;
    { retrieve an option by its long form. Case sensitivity of the options
      is taken into account! }
    property ByName[const AName: string]: TOption read GetOptionByLongName;
    { retrieve an option by its short form. Case sensitivity of the options
      is taken into account! }
    property ByShortName[const AName: char]: TOption read GetOptionByShortname;
    { introductory character to be used for short options }
    property ShortOptionStart: Char read FShortOptionChar write FShortOptionChar default DefShortOptionChar;
    { introductory string to be used for long options }
    property LongOptionStart: String read FLongOptionString write FLongOptionString;
    { name of an option to include config file }
    property IncludeFileOptionName: string read FIncludeFileOptionName write FIncludeFileOptionName;
    { explanation of an option to include config file }
    property IncludeFileOptionExpl: string read FIncludeFileOptionExpl write FIncludeFileOptionExpl;
  end;

implementation
uses
  SysUtils,
  PasDoc_Utils, PasDoc_Types;

function TryStrToInt(const AString: string; var AValue: Integer): Boolean;
var
  LError: Integer;
begin
  Val(AString, AValue, LError);
  Result := LError = 0;
end;

// Text wrapping done right. RTL versions in both Delphi and FPC wrap at first
// wordwrap symbol AFTER MaxCol (so most of sublines are longer than MaxCol) -
// and that is quite useless. This function ensures no subline is longer than
// MaxCol except for those which do not have a wordbreak inside.
function CorrectWrapText(const Line: string; MaxCol: Integer): string;
const
  WordBreaks: TCharSet = [' ', '-', #9];
  LineBreaks: TCharSet = [#13, #10];
  LineBreak = LineEnding;
var
  WBPos, SubLineStartPos, NextMaxPos, i: Integer;
begin
  Result := ''; WBPos := 1; SubLineStartPos := 1; NextMaxPos := MaxCol;

  for i := 1 to Length(Line) do
  begin
    if CharInSet(Line[i], WordBreaks) then
      WBPos := i
    else
    if CharInSet(Line[i], LineBreaks) then // leave line breaks that are already there
    begin
      NextMaxPos := i + MaxCol;
      Continue;
    end;
    if i <= NextMaxPos then Continue;
    if WBPos = 0 then Continue;   // no word breaks in the current subline - wait for the first one
    Result := Result + Copy(Line, SubLineStartPos, WBPos - SubLineStartPos) + LineBreak;
    SubLineStartPos := WBPos + 1; // do not include the word break we broke at
    NextMaxPos := WBPos + MaxCol; // limit the next subline
    WBPos := 0;
  end;
  Result := Result + Copy(Line, SubLineStartPos, MaxInt);
end;

// Write option name and explanation to console in two columns.
// NameColWidth is width of the first column not including indent and separator
procedure WriteOptionInfo(const Name, Explanation: string; NameColWidth: Integer);
var
  i, LWritten, ExplWidth: Integer;
  LLines: TStringList;
begin
  Write(OptionIndent, Name);
  LWritten := Length(OptionIndent) + Length(Name);
  // 1 here comes from a glitch: if a line to be written finishes exactly at the
  // right edge of console, an extra line feed is written after it. So we keep
  // 1-char margin from the right edge.
  ExplWidth := ConsoleWidth - Length(OptionIndent) - NameColWidth - Length(OptionSep) - 1;
  LLines := TStringList.Create;
  LLines.Text := CorrectWrapText(Explanation, ExplWidth);
  for i := 0 to LLines.Count-1 do
  begin
    WriteLn(StringOfChar(' ', NameColWidth + Length(OptionIndent) - LWritten), OptionSep, LLines[i]);
    LWritten := 0; // this value was needed for the 1st iteration only
  end;
  LLines.Free;
end;

{ TOptionParser }

constructor TOptionParser.Create;
begin
  CreateParams(nil);
end;

function TOptionParser.AddOption(const AOption: TOption): TOption;
begin
  FOptions.Add(AOption);
  Result := AOption;
  AOption.FParser := Self;
end;

constructor TOptionParser.CreateParams(const AParams: TStrings);
var
  i: Integer;
begin
  inherited Create;
  FParams := TStringList.Create;
  if Assigned(AParams) then begin
    FParams.Assign(AParams);
  end else begin
    for i := 1 to ParamCount do begin
      FParams.Add(ParamStr(i));
    end;
  end;

  FLeftList := TStringList.Create;
  FOptions := TList.Create;

  FLongOptionString := DefLongOptionString;
  FShortOptionChar := DefShortOptionChar;
end;

destructor TOptionParser.Destroy;
var
  i: Integer;
begin
  for i := FOptions.Count-1 downto 0 do begin
    TOption(FOptions[i]).Free;
  end;
  FLeftList.Free;
  FParams.Free;
  FOptions.Free;
  inherited;
end;

procedure TOptionParser.ParseOptions;

  procedure ExpandCfgFileMacros(List: TStringList; const CfgFile: string);
  var
    i: Integer;
    CfgFilePath: string;
  begin
    // Prepare macros data
    CfgFilePath := ExtractFileDir(ExpandFileName(CfgFile));

    // Do the replace. This might be optimized if the number of macros grows
    for i := 0 to List.Count - 1 do
      List[i] := StringReplace(List[i], CfgMacroCfgPath, CfgFilePath, [rfReplaceAll]);
  end;

var
  LCopyList, OptsFromFile: TStringList;
  i, j: Integer;
  CfgFile: string;
  LFoundSomething: boolean;
begin
  LCopyList := TStringList.Create;
  LCopyList.Assign(FParams);
  FLeftList.Clear;

  try
    // Pre-process config files ("@<path-to-file>" parameters)
    for i := LCopyList.Count - 1 downto 0 do
      if SCharIs(LCopyList[i], 1, OptionFileChar) then
      begin
        // read config file
        OptsFromFile := TStringList.Create;
        try
          CfgFile := Copy(LCopyList[i], 2, MaxInt);
          OptsFromFile.LoadFromFile(CfgFile);
          ExpandCfgFileMacros(OptsFromFile, CfgFile);
          // add to the list at current position
          for j := OptsFromFile.Count - 1 downto 0 do
            LCopyList.Insert(i + 1, LongOptionStart + OptsFromFile[j]);
        finally
          FreeAndNil(OptsFromFile);
          // remove the option with file path
          LCopyList.Delete(i);
        end;
      end;

    while LCopyList.Count > 0 do begin
      LFoundSomething := false;
      for i := 0 to FOptions.Count-1 do begin
        if TOption(FOptions[i]).ParseOption(LCopyList) then begin
          LFoundSomething := true;
          break;
        end;
      end;
      if not LFoundSomething then begin
        FLeftList.Add(LCopyList[0]);
        LCopyList.Delete(0);
      end;
    end;
  finally
    LCopyList.Free;
  end;
end;

function TOptionParser.GetOptionsCount: Integer;
begin
  Result := FOptions.Count;
end;

function TOptionParser.GetOption(const AIndex: Integer): TOption;
begin
  Result := TOption(FOptions[AIndex]);
end;

procedure TOptionParser.WriteExplanations;
  function Max(const A,B: Integer): Integer;
  begin
    if A>B then Result := A else Result := B;
  end;

var
  i, LMaxWidth: Integer;
begin
  LMaxWidth := 0;
  for i := 0 to OptionsCount-1 do
    LMaxWidth := Max(LMaxWidth, Options[i].GetOptionWidth);

  // first write explanation for config file
  WriteOptionInfo(FIncludeFileOptionName, FIncludeFileOptionExpl, LMaxWidth);

  for i := 0 to OptionsCount-1 do begin
    Options[i].WriteExplanation(LMaxWidth);
  end;
end;

function TOptionParser.GetOptionByLongName(const AName: string): TOption;
var
  i: Integer;
begin
  Result := nil;
  for i := GetOptionsCount-1 downto 0 do begin
    if  (Options[i].LongForm = AName)
        OR (Options[i].LongCaseSensitive AND (LowerCase(Options[i].LongForm) = LowerCase(AName))) then begin
      Result := Options[i];
      break;
    end;
  end;
end;

function TOptionParser.GetOptionByShortname(const AName: char): TOption;
var
  i: Integer;
begin
  Result := nil;
  for i := GetOptionsCount-1 downto 0 do begin
    if  (Options[i].ShortForm = AName)
        OR (Options[i].LongCaseSensitive AND (LowerCase(Options[i].ShortForm) = LowerCase(AName))) then begin
      Result := Options[i];
      break;
    end;
  end;
end;

{ TOption }

constructor TOption.CreateEx(const AShort: char; const ALong: string;
  const AShortCaseSensitive, ALongCaseSensitive: boolean);
begin
  inherited Create;
  FShort := AShort;
  FLong := ALong;
  FShortSens := AShortCaseSensitive;
  FLongSens := ALongCaseSensitive;
end;

constructor TOption.Create(const AShort: char; const ALong: string);
begin
  CreateEx(AShort, ALong, True, False);
end;

function TOption.GetOptionWidth: Integer;
begin
  Result := 0;
  if ShortForm<>#0 then begin
    Inc(Result, 4); // "-x, "
  end;
  if Length(LongForm)>0 then begin
    Inc(Result, Length(LongForm)+Length(FParser.LongOptionStart));
  end else begin
    Dec(Result, 2);
  end;
end;

procedure TOption.WriteExplanation(const AOptWidth: Integer);
var
  Name: string;
begin
  Name := '';
  if ShortForm <> #0 then begin
    Name := FParser.ShortOptionStart + ShortForm;
    if Length(LongForm) > 0 then
      Name := Name + ', ';
  end;
  if Length(LongForm) > 0 then
    Name := Name + FParser.LongOptionStart + LongForm;
  WriteOptionInfo(Name, Explanation, AOptWidth);
end;

{ TBoolOption }

function TBoolOption.ParseOption(const AWords: TStrings): boolean;
begin
  Result := False;
  if ShortForm <> #0 then begin
    if AWords[0] = FParser.ShortOptionStart+ShortForm then begin
      Result := True;
      AWords.Delete(0);
      FWasSpecified := True;
    end else begin
      if (not ShortCaseSensitive) and (LowerCase(AWords[0]) = FParser.ShortOptionStart+LowerCase(ShortForm)) then begin
        Result := True;
        AWords.Delete(0);
        FWasSpecified := True;
      end;
    end;
  end;

  if (not Result) and (Length(LongForm) > 0) then begin
    if AWords[0] = FParser.LongOptionStart+LongForm then begin
      Result := True;
      AWords.Delete(0);
      FWasSpecified := True;
    end else begin
      if (not LongCaseSensitive) and (LowerCase(AWords[0]) = FParser.LongOptionStart+LowerCase(LongForm)) then begin
        Result := True;
        AWords.Delete(0);
        FWasSpecified := True;
      end;
    end;
  end;
end;

{ TValueOption }

function TValueOption.ParseOption(const AWords: TStrings): boolean;
var
  LValue: string;
begin
  Result := False;
  if ShortForm <> #0 then begin
    if (Copy(AWords[0],1,Length(FParser.ShortOptionStart+ShortForm)) = FParser.ShortOptionStart+ShortForm)
      OR ((not ShortCaseSensitive) and (LowerCase(Copy(AWords[0],1,Length(FParser.ShortOptionStart+ShortForm))) = FParser.ShortOptionStart+LowerCase(ShortForm))) then begin
      LValue := Copy(AWords[0], Length(FParser.ShortOptionStart+ShortForm)+1, MaxInt);
      if LValue = '' then begin
        if AWords.Count>1 then begin
          LValue := AWords[1];
          if CheckValue(LValue) then begin
            Result := True;
            AWords.Delete(0);
            AWords.Delete(0);
          end else begin
            Result := CheckValue('');
            if Result then AWords.Delete(0);
          end;
        end else begin
          Result := CheckValue(LValue);
          if Result then AWords.Delete(0);
        end;
      end else begin
        Result := CheckValue(LValue);
        if Result then AWords.Delete(0);
      end;
    end;
  end;
  if Result then FWasSpecified := True;
  if (not Result) and (Length(LongForm) > 0) then begin
    if (Copy(AWords[0],1,Length(FParser.LongOptionStart+LongForm)) = FParser.LongOptionStart+LongForm)
      OR ((not LongCaseSensitive) AND (LowerCase(Copy(AWords[0],1,Length(FParser.LongOptionStart+LongForm))) = FParser.LongOptionStart+LowerCase(LongForm))) then begin
      if Length(AWords[0]) = Length(FParser.LongOptionStart+LongForm) then begin
        if AWords.Count>1 then begin
          LValue := AWords[1];
        end else begin
          LValue := '';
        end;
        Result := CheckValue(LValue);
        if Result then begin
          AWords.Delete(0);
          if AWords.Count>0 then AWords.Delete(0);
        end;
      end else begin
        if Copy(AWords[0], Length(FParser.LongOptionStart+LongForm)+1, 1) = '=' then begin
          LValue := Copy(AWords[0], Length(FParser.LongOptionStart+LongForm)+2, MaxInt);
          Result := CheckValue(LValue);
          if Result then AWords.Delete(0);
        end;
      end;
    end;
  end;
  if Result then FWasSpecified := True;
end;

{ TIntegerOption }

function TIntegerOption.CheckValue(const AString: String): boolean;
var
  LValue: Integer;
begin
  Result := TryStrToInt(AString, LValue);
  if Result then FValue := LValue;
end;

{ TStringOption }

function TStringOption.CheckValue(const AString: String): boolean;
begin
  FValue := AString;
  Result := True;
end;

{ TStringOptionList }

function TStringOptionList.CheckValue(const AString: String): Boolean;
begin
  Result := True;
  FValues.Add(AString);
end;

constructor TStringOptionList.CreateEx(const AShort: Char;
  const ALong: String; const AShortCaseSensitive,
  ALongCaseSensitive: Boolean);
begin
  inherited;
  FValues := TStringList.Create;
end;

destructor TStringOptionList.Destroy;
begin
  FValues.Free;
  inherited;
end;

{ TSetOption }

function TSetOption.CheckValue(const AString: String): Boolean;
var
  NewValues: TStringList;
  S, ValueName: string;
begin
  NewValues := TStringList.Create;
  try
    NewValues.CommaText := AString;
    for S in NewValues do
    begin
      ValueName := RemoveSuffix('?', S);
      if FPossibleValues.IndexOf(ValueName) < 0 then
        Exit(false);
    end;

    FValues.CommaText := AString;
    Result := true;
  finally
    FreeAndNil(NewValues);
  end;
end;

constructor TSetOption.CreateEx(const AShort: Char; const ALong: String;
  const AShortCaseSensitive, ALongCaseSensitive: Boolean);
begin
  inherited;
  FPossibleValues := TStringList.Create;
  FPossibleValues.Duplicates := dupIgnore;
  FPossibleValues.Sorted := True;
  FValues := TStringList.Create;
  FValues.Duplicates := dupIgnore;
  FValues.Sorted := True;
end;

destructor TSetOption.Destroy;
begin
  FPossibleValues.Free;
  FValues.Free;
  inherited;
end;

function TSetOption.GetPossibleValues: string;
begin
  Result := FPossibleValues.CommaText;
end;

function TSetOption.GetValues: string;
begin
  Result := FValues.CommaText;
end;

function TSetOption.HasValue(const AValue: string): boolean;
begin
  Assert(not IsSuffix('?', AValue), 'checked AValue should not have ? suffix');
  Result :=
    (FValues.IndexOf(AValue) >=0) or
    (FValues.IndexOf(AValue + '?') >=0);
end;

function TSetOption.HasToggleValue(const AValue: string): boolean;
begin
  Assert(not IsSuffix('?', AValue), 'checked AValue should not have ? suffix');
  Result :=
    (FValues.IndexOf(AValue + '?') >=0);
end;

procedure TSetOption.SetPossibleValues(const Value: string);
begin
  FPossibleValues.CommaText := Value;
end;

procedure TSetOption.SetValues(const Value: string);
begin
  FValues.CommaText := Value;
end;

{ TPathListOption }

{$IFDEF FPC}
const
  sLineBreak = LineEnding;
  PathSep    = PathSeparator;
{$ENDIF}

function TPathListOption.CheckValue(const AString: String): Boolean;
var
  LValues: TStringList;
begin
  Result := true;
  LValues := TStringList.Create;
  LValues.Text := StringReplace(AString, PathSep, sLineBreak, [rfReplaceAll]);
  FValues.AddStrings(LValues);
  LValues.Free;
end;

end.
