4
\$\begingroup\$

I've written an object which allows parsing and serializing a command line. I don't in any way consider this done, but this is the beginning of it. I know there are other implementations out there like this, but they tend to be either too primitive or too heavy. This is an attempt to simplify parsing and serializing a command line in Windows, consisting of the application, any file(s) to be opened, and any additional parameters.

The reason I cannot use the built-in param switches is because I need to be able to feed a string from one application instance to another, while enforcing only one instance of the application. For example, lets say there's already an application instance open, and the user chooses to open an existing file, and provide specific parameters to control how it's opened. The new instance will detect that there's already an instance open, and forward the command line to it before terminating itself. Then, the existing instance receives and processes that command line using this parser.

Before I move much further with this object, do you see anything critically wrong with it?

unit CmdLine;

(*
  Command Line Parser
  by Jerry Dodge

  Class: TCmdLine
  - Parses out a command line into individual name/value pairs
  - Concatenates name/value pairs into a command line string
  - Property "ModuleFilename" for the current executable path
  - Property "OpenFilename" for the file to be opened, if any
  - Default property "Values" to read/write name/value pairs
*)

interface

uses
  System.Classes, System.SysUtils;

type
  TCmdLine = class(TObject)
  private
    FItems: TStringList;
    FModuleFilename: String;
    FOpenFilename: String;
    function GetAsString: String;
    procedure SetAsString(const Value: String);
    procedure SetModuleFilename(const Value: String);
    procedure SetOpenFilename(const Value: String);
    function GetValue(const Name: String): String;
    procedure SetValue(const Name, Value: String);
    function GetName(const Index: Integer): String;
  public
    constructor Create;
    destructor Destroy; override;
    function Count: Integer;
    function Exists(const N: String; const IgnoreCase: Boolean = False): Boolean;
    property ModuleFilename: String read FModuleFilename write SetModuleFilename;
    property OpenFilename: String read FOpenFilename write SetOpenFilename;
    property AsString: String read GetAsString write SetAsString;
    property Names[const Index: Integer]: String read GetName;
    property Values[const Name: String]: String read GetValue write SetValue; default;
  end;

implementation

{ TCmdLine }

constructor TCmdLine.Create;
begin
  FItems:= TStringList.Create;
end;

destructor TCmdLine.Destroy;
begin
  FItems.Free;
  inherited;
end;

function TCmdLine.Count: Integer;
begin
  Result:= FItems.Count;
end;

function TCmdLine.Exists(const N: String; const IgnoreCase: Boolean = False): Boolean;
var
  X: Integer;
begin
  Result:= False;
  for X := 0 to FItems.Count-1 do begin
    if IgnoreCase then begin
      if SameText(N, FItems.Names[X]) then begin
        Result:= True;
        Break;
      end;
    end else begin
      if N = FItems.Names[X] then begin
        Result:= True;
        Break;
      end;
    end;
  end;
end;

procedure TCmdLine.SetModuleFilename(const Value: String);
begin
  FModuleFilename:= Value;
end;

procedure TCmdLine.SetOpenFilename(const Value: String);
begin
  FOpenFilename:= Value;
end;

function TCmdLine.GetValue(const Name: String): String;
begin
  Result:= FItems.Values[Name];
end;

procedure TCmdLine.SetValue(const Name, Value: String);
begin
  FItems.Values[Name]:= Value;
end;

function TCmdLine.GetAsString: String;
var
  X: Integer;
  Cmd: String;
  Val: String;
begin
  Result:= '"'+FModuleFilename+'"';
  if Trim(FOpenFilename) <> '' then
    Result:= Result + ' "'+FOpenFilename+'"';
  for X := 0 to FItems.Count-1 do begin
    Cmd:= FItems.Names[X];
    Val:= FItems.Values[Cmd];
    Result:= Result + ' -'+Cmd;
    if Trim(Val) <> '' then begin
      Result:= Result + ' ';
      if Pos(' ', Val) > 0 then
        Result:= Result + '"'+Val+'"'
      else
        Result:= Result + Val;
    end;
  end;
end;

function TCmdLine.GetName(const Index: Integer): String;
begin
  Result:= FItems.Names[Index];
end;

procedure TCmdLine.SetAsString(const Value: String);
var
  Str: String;
  Tmp: String;
  Cmd: String;
  Val: String;
  P: Integer;
begin
  FItems.Clear;
  FModuleFilename:= '';
  FOpenFilename:= '';
  Str:= Trim(Value) + ' ';

  //Extract module filename
  P:= Pos('"', Str);
  if P = 1 then begin
    //Module filename is wrapped in ""
    Delete(Str, 1, 1);
    P:= Pos('"', Str);
    Tmp:= Copy(Str, 1, P-1);
    Delete(Str, 1, P);
    FModuleFilename:= Tmp;
  end else begin
    //Module filename is not wrapped in ""
    P:= Pos(' ', Str);
    Tmp:= Copy(Str, 1, P-1);
    Delete(Str, 1, P);
    FModuleFilename:= Tmp;
  end;

  Str:= Trim(Str) + ' ';

  //Extract open filename
  P:= Pos('"', Str);
  if P = 1 then begin
    //Open filename is wrapped in ""
    Delete(Str, 1, 1);
    P:= Pos('"', Str);
    Tmp:= Copy(Str, 1, P-1);
    Delete(Str, 1, P);
    FOpenFilename:= Tmp;
  end else begin
    //Open filename is not wrapped in ""
    P:= Pos('-', Str);
    if P < 1 then
      P:= Pos('/', 'Str');
    if P < 1 then begin
      //Param does not have switch name
      P:= Pos(' ', Str);
      Tmp:= Copy(Str, 1, P-1);
      Delete(Str, 1, P);
      FOpenFilename:= Tmp;
    end;
  end;

  Str:= Trim(Str) + ' ';

  //Extract remaining param switches/values
  while Length(Trim(Str)) > 0 do begin
    P:= Pos('-', Str);
    if P < 1 then
      P:= Pos('/', 'Str');
    if P > 0 then begin
      //Param switch prefix found
      Delete(Str, 1, 1);
      P:= Pos(' ', Str);
      Tmp:= Trim(Copy(Str, 1, P-1)); //Switch name
      Delete(Str, 1, P);
      Cmd:= Tmp;
      Str:= Trim(Str) + ' ';

      if (Pos('-', Str) <> 1) and  (Pos('/', Str) <> 1) then begin
        //This parameter has a value associated with it
        P:= Pos('"', Str);
        if P = 1 then begin
          //Value is wrapped in ""
          Delete(Str, 1, 1);
          P:= Pos('"', Str);
          Tmp:= Copy(Str, 1, P-1);
          Delete(Str, 1, P);
        end else begin
          //Value is not wrapped in ""
          P:= Pos(' ', Str);
          Tmp:= Copy(Str, 1, P-1);
          Delete(Str, 1, P);
        end;
        Val:= Tmp;
      end else begin
        Val:= '';
      end;
      //If blank, add space to ensure at least name gets added
      if Val = '' then
        Val:= ' ';
      FItems.Values[Cmd]:= Val;
    end else begin
      Str:= '';
      raise Exception.Create('Command line parameters malformed ('+Str+')');
    end;
    Str:= Trim(Str) + ' ';
  end;
end;

end.

NOTE: The primary procedure to be reviewed is TCmdLine.SetAsString.

Sample Usage

CmdLine.AsString := '"C:\MyApp.exe" "C:\SomeFile.txt" -n -o "Some Value With Spaces" -f SomeOtherValueWithNoSpaces -p';

Result

  • ModuleFilename = C:\MyApp.exe
  • OpenFilename = C:\SomeFile.txt
  • Param n = [BLANK]
  • Param o = Some Value With Spaces
  • Param f = SomeOtherValueWithNoSpaces
  • Param p = [BLANK]
\$\endgroup\$
6
  • \$\begingroup\$ Your sample usage is broken, you should create the object first. Could you edit your question? \$\endgroup\$
    – cpicanco
    Commented Nov 19, 2015 at 16:41
  • \$\begingroup\$ FCmdLine := TCmdLine.Create; FCmdLine.AsString := '"C:\MyApp.exe" "C:\SomeFile.txt" -n -o "Some Value With Spaces" -f SomeOtherValueWithNoSpaces -p'; WriteLn(FCmdLine.AsString); ReadLn; \$\endgroup\$
    – cpicanco
    Commented Nov 19, 2015 at 17:07
  • \$\begingroup\$ @cpicanco That should be common sense with any object... \$\endgroup\$ Commented Nov 19, 2015 at 17:18
  • \$\begingroup\$ You increase your chances making life easier. Also, IMHO being explicit on usage is preferable. \$\endgroup\$
    – cpicanco
    Commented Nov 19, 2015 at 20:10
  • \$\begingroup\$ @cpicanco Never had that problem on Stack Overflow. They insist on making examples as short as possible. That would be "beating around the bush". \$\endgroup\$ Commented Nov 19, 2015 at 20:28

1 Answer 1

1
\$\begingroup\$

I think this should work, but I think you could write a class such that the code that used it was more readable. Why not narrow it down to just three functions -

IsArg (return true if a switch is present, else false)

GetArg (take a switch, return the value if any)

GetDelimitedArg (take a switch and a delimiter, return an array result)

Example:

unit CLArgParser;
//this class makes it easier to parse command line arguments
interface

uses
  Classes;

type
  strarr = array of string;

type
  TCLArgParser = class
  private
    FPermitTags : array of string;
    FTrimAll: boolean;
  public
    function IsArg(argtag : string) : boolean;
    function GetArg(argtag : string) : string;
    function GetDelimtedArg(argtag, delimiter : string) : TStringList;
    constructor Create(ArgTags : array of string); overload;
    constructor Create; overload;

    property TrimAll: boolean read FTrimAll write FTrimAll;
  end;

implementation

uses
  SysUtils;

const
  cDefaultTags : array[0..1] of string =  ('-','/');

constructor TCLArgParser.Create(ArgTags : array of string);
var i : integer;
begin
  try
    SetLength(FPermitTags,High(ArgTags)+1);
    for i := 0 to High(ArgTags) do begin
      FPermitTags[i] := ArgTags[i];
    end;  //for i
  except on e : exception do
    raise;
  end;  //try-except
end;

constructor TCLArgParser.Create;
begin
  FTrimAll := False;  //default value
  inherited Create;
  Create(cDefaultTags);
end;

function TCLArgParser.GetArg(argtag: string): string;
var i,j,n : integer;
begin
  try
    Result := '';
    n := High(FPermitTags);

    for i := 1 to ParamCount do
      for j := 0 to n do
        if Uppercase(ParamStr(i)) = (FPermitTags[j] + Uppercase(argtag)) then
          Result := ParamStr(i+1);

    if FTrimAll then begin
      Result := Trim(Result);
    end;
  except on e : exception do
    raise;
  end;  //try-except
end;

function TCLArgParser.GetDelimtedArg(argtag, delimiter: string): TStringList;
var i : integer;
    argval, tmp : string;
begin
  try
    Result := TStringList.Create;
    argval := GetArg(argtag);

    for i := 1 to Length(argval) do begin
      if ((i = Length(argval)) or ((argval[i] = delimiter) and (tmp <> '')))
      then begin
        if i = Length(argval) then begin
          tmp := tmp + argval[i];
          if FTrimAll then begin
            tmp := Trim(tmp);
          end;
        end;
        Result.Add(tmp);
        tmp := '';
      end  //if we found a delimted value
      else begin
        tmp := tmp + argval[i];
      end;  //else we just keep looking
    end;  //for ea. character

  except on e : exception do
    raise;
  end;  //try-except
end;

function TCLArgParser.IsArg(argtag: string): boolean;
var i,j,n : integer;
begin
  try
    Result := False;
    n := High(FPermitTags);

    for i := 1 to ParamCount do begin
      for j := 0 to n do begin
        if Uppercase(ParamStr(i)) = (FPermitTags[j] + Uppercase(argtag))
        then begin
          Result := True;
          Exit;
        end;  //if we found it
      end;  //for j
    end;  //for i
  except on e : exception do
    raise;
  end;  //try-except
end;

end.
\$\endgroup\$
1
  • \$\begingroup\$ We tend to prefer answers that include more about why to do things. Why just three? Why not two? Or four? Why implement those three the way that you did? \$\endgroup\$
    – mdfst13
    Commented Nov 19, 2015 at 19:23

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.