unit u_dcd;

{$I u_defines.inc}

interface

uses
  Classes, SysUtils, process, forms, strutils, LazFileUtils,
  {$IFDEF WINDOWS}
  windows,
  {$ENDIF}
  u_common, u_writableComponent, u_interfaces, u_observer, u_synmemo,
  u_stringrange, u_projutils, u_semver;

type

  TIntOpenArray = array of integer;

  (**
   * Wrap the dcd-server and dcd-client processes.
   *
   * Projects folders are automatically imported: IProjectObserver.
   * Completion, hints and declaration finder automatically work on the current
   *   document: IDocumentObserver.
   *)
  TDcdWrapper = class(TWritableLfmTextComponent, IProjectObserver, IDocumentObserver)
  private
    fTempLines: TStringList;
    fInputSource: string;
    fImportCache: TStringHashSet;
    fPortNum: Word;
    fCurrentSessionPortNum: Word;
    fServerWasRunning: boolean;
    fClient, fServer: TProcess;
    fAvailable: boolean;
    fServerListening: boolean;
    fDoc: TDexedMemo;
    fProj: ICommonProject;
    fPortAsProcParam: string;
    fVersion: TSemVer;
    fCanRemove: boolean;
    procedure killServer;
    procedure terminateClient; {$IFNDEF DEBUG}inline;{$ENDIF}
    procedure waitClient; {$IFNDEF DEBUG}inline;{$ENDIF}
    procedure updateServerlistening;
    procedure writeSourceToInput; {$IFNDEF DEBUG}inline;{$ENDIF}
    function checkDcdSocket: boolean;
    function getIfLaunched: boolean;
    procedure tryAddTcpParams; {$IFNDEF DEBUG}inline;{$ENDIF}
    procedure updateImportPathsFromProject;
    //
    procedure projNew(project: ICommonProject);
    procedure projChanged(project: ICommonProject);
    procedure projClosing(project: ICommonProject);
    procedure projFocused(project: ICommonProject);
    procedure projCompiling(project: ICommonProject);
    procedure projCompiled(project: ICommonProject; success: boolean);
    //
    procedure docNew(document: TDexedMemo);
    procedure docFocused(document: TDexedMemo);
    procedure docChanged(document: TDexedMemo);
    procedure docClosing(document: TDexedMemo);
  published
    property port: word read fPortNum write fPortNum default 0;
  public
    constructor create(aOwner: TComponent); override;
    destructor destroy; override;
    //
    class procedure relaunch; static;
    class function noDcdPassedAsArg: boolean; static;
    //
    procedure addImportFolders(const folders: TStrings);
    procedure addImportFolder(const folder: string);
    procedure remImportFolder(const folder: string);
    procedure remImportFolders(const folders: TStrings);
    procedure getComplAtCursor(list: TStringList);
    procedure getCallTip(out tips: string);
    procedure getDdocFromCursor(out comment: string);
    procedure getDeclFromCursor(out fname: string; out position: Integer);
    procedure getLocalSymbolUsageFromCursor(var locs: TIntOpenArray);
    //
    property available: boolean read fAvailable;
    property launchedByCe: boolean read getIfLaunched;
  end;

    function DCDWrapper: TDcdWrapper;

implementation

var
  fDcdWrapper: TDcdWrapper = nil;

const
  clientName = 'dcd-client' + exeExt;
  serverName = 'dcd-server' + exeExt;
  optsname = 'dcdoptions.txt';


{$REGION Standard Comp/Obj------------------------------------------------------}
constructor TDcdWrapper.create(aOwner: TComponent);
var
  fname: string;
  i: integer = 0;
  r: TSemVer;
begin
  inherited;

  fVersion.init('v0.0.0');
  fname := getDocPath + optsname;
  if fname.fileExists then
    loadFromFile(fname);
  fCurrentSessionPortNum := fPortNum;
  fPortAsProcParam := '-p' + intToStr(fCurrentSessionPortNum);

  fAvailable := exeInSysPath(clientName) and exeInSysPath(serverName)
    and not noDcdPassedAsArg();
  if not fAvailable then
    exit;

  fClient := TProcess.Create(self);
  fClient.Executable := exeFullName(clientName);
  fClient.Options := [poUsePipes{$IFDEF WINDOWS}, poNewConsole{$ENDIF}];
  fClient.ShowWindow := swoHIDE;

  fServerWasRunning := AppIsRunning((serverName));
  if not fServerWasRunning then
  begin
    fServer := TProcess.Create(self);
    fServer.Executable := exeFullName(serverName);
    fServer.Options := [{$IFDEF WINDOWS} poNewConsole{$ENDIF}];
    {$IFNDEF DEBUG}
    fServer.ShowWindow := swoHIDE;
    {$ENDIF}
    if fCurrentSessionPortNum <> 0 then
    begin
      fServer.Parameters.Add('--tcp');
      fServer.Parameters.Add(fPortAsProcParam);
    end;
  end;
  fTempLines := TStringList.Create;
  fImportCache := TStringHashSet.Create;

  fClient.Parameters.Add('--version');
  fClient.Execute;
  processOutputToStrings(fClient, fTempLines);
  while fClient.Running do ;
  fVersion.init(fTempLines.strictText);
  r.major := 0;
  r.minor := 9;
  r.patch := 10;
  fCanRemove := fVersion > r;

  if fServer.isNotNil then
  begin
    fServer.Execute;
    while true do
    begin
      if (i = 10) or checkDcdSocket then
        break;
      i += 1;
    end;
  end;
  updateServerlistening;

  EntitiesConnector.addObserver(self);
end;

class function TDcdWrapper.noDcdPassedAsArg(): boolean;
var
  i: integer;
begin
  result := false;
  for i := 1 to argc-1 do
    if ParamStr(i) = '-nodcd' then
  begin
    result :=true;
    break;
  end;
end;

class procedure TDcdWrapper.relaunch;
begin
  fDcdWrapper.Free;
  fDcdWrapper := TDcdWrapper.create(nil);
end;

function TDcdWrapper.getIfLaunched: boolean;
begin
  result := fServer.isNotNil;
end;

procedure TDcdWrapper.updateServerlistening;
begin
  fServerListening := AppIsRunning((serverName));
end;

destructor TDcdWrapper.destroy;
var
  i: integer = 0;
begin
  saveToFile(getDocPath + optsname);
  EntitiesConnector.removeObserver(self);
  fImportCache.Free;
  if fTempLines.isNotNil then
    fTempLines.Free;
  if fServer.isNotNil then
  begin
    if not fServerWasRunning then
    begin
      killServer;
      while true do
      begin
        if (not checkDcdSocket) or (i = 10) then
          break;
        i +=1;
      end;
    end;
    fServer.Terminate(0);
    fServer.Free;
  end;
  fClient.Free;
  inherited;
end;
{$ENDREGION}

{$REGION IProjectObserver ----------------------------------------------------}
procedure TDcdWrapper.updateImportPathsFromProject;
var
  i: Integer;
  fold: string;
  folds: TStringList;
begin
  if not assigned(fProj) then
    exit;

  folds := TStringList.Create;
  try
    fold := u_projutils.projectSourcePath(fProj);
    if fold.dirExists then
      folds.Add(fold);
  	for i := 0 to fProj.importsPathCount-1 do
  	begin
    	fold := fProj.importPath(i);
      if fold.dirExists and (folds.IndexOf(fold) = -1) then
        folds.Add(fold);
    end;
    addImportFolders(folds);
  finally
    folds.Free;
  end;
end;

procedure TDcdWrapper.projNew(project: ICommonProject);
begin
  fProj := project;
end;

procedure TDcdWrapper.projChanged(project: ICommonProject);
begin
  if (fProj = nil) or (fProj <> project) then
    exit;
  updateImportPathsFromProject();
end;

procedure TDcdWrapper.projClosing(project: ICommonProject);
begin
  if fProj <> project then
    exit;
  fProj := nil;
end;

procedure TDcdWrapper.projFocused(project: ICommonProject);
begin
  fProj := project;
  updateImportPathsFromProject();
end;

procedure TDcdWrapper.projCompiling(project: ICommonProject);
begin
end;

procedure TDcdWrapper.projCompiled(project: ICommonProject; success: boolean);
begin
end;
{$ENDREGION}

{$REGION IDocumentObserver ---------------------------------------------------}
procedure TDcdWrapper.docNew(document: TDexedMemo);
begin
  fDoc := document;
end;

procedure TDcdWrapper.docFocused(document: TDexedMemo);
begin
  fDoc := document;
end;

procedure TDcdWrapper.docChanged(document: TDexedMemo);
begin
  if fDoc <> document then exit;
end;

procedure TDcdWrapper.docClosing(document: TDexedMemo);
begin
  if fDoc <> document then exit;
  fDoc := nil;
end;
{$ENDREGION}

{$REGION DCD things ------------------------------------------------------------}
procedure TDcdWrapper.terminateClient;
begin
  if fClient.Running then
    fClient.Terminate(0);
end;

function TDcdWrapper.checkDcdSocket: boolean;
var
  str: string;
  prt: word = 9166;
  prc: TProcess;
  lst: TStringList;
begin
  sleep(100);
  // nix/osx: the file might exists from a previous session that crashed
  // however the 100 ms might be enough for DCD initialization
  if fCurrentSessionPortNum = 0 then
  begin
    {$IFDEF LINUX}
    str := sysutils.GetEnvironmentVariable('XDG_RUNTIME_DIR');
    if (str + DirectorySeparator + 'dcd.socket').fileExists then
      exit(true);
    str := sysutils.GetEnvironmentVariable('UID');
    if ('/tmp/dcd-' + str + '.socket').fileExists then
      exit(true);
    {$ENDIF}
    {$IFDEF DARWIN}
    str := sysutils.GetEnvironmentVariable('UID');
    if ('/var/tmp/dcd-' + str + '.socket').fileExists then
      exit(true);
    {$ENDIF}
  end;
  result := false;
  if port <> 0 then
    prt := port;
  prc := TProcess.Create(nil);
  try
    prc.Options:= [poUsePipes, poNoConsole];
    prc.Executable := 'netstat';
    prc.Parameters.Add('-o');
    prc.Parameters.Add('-a');
    prc.Parameters.Add('-n');
    prc.Execute;
    lst := TStringList.Create;
    try
      processOutputToStrings(prc,lst);
      while prc.Running do ;
      for str in lst do
      if AnsiContainsText(str, '127.0.0.1:' + intToStr(prt))
      and AnsiContainsText(str, 'TCP')
      and AnsiContainsText(str, 'LISTEN') then
      begin
        result := true;
        break;
      end;
    finally
      lst.Free;
    end;
  finally
    prc.Free;
  end;
  exit(result);
end;

procedure TDcdWrapper.tryAddTcpParams;
begin
  if fCurrentSessionPortNum <> 0 then
  begin
    fClient.Parameters.Add('--tcp');
    fClient.Parameters.Add(fPortAsProcParam);
  end;
end;

procedure TDcdWrapper.killServer;
begin
  if not fAvailable or not fServerListening then
    exit;

  fClient.Parameters.Clear;
  tryAddTcpParams;
  fClient.Parameters.Add('--shutdown');
  fClient.Execute;
  while fServer.Running or fClient.Running do
    sleep(50);
end;

procedure TDcdWrapper.waitClient;
begin
  while fClient.Running do
    sleep(5);
end;

procedure TDcdWrapper.writeSourceToInput;
begin
  fInputSource := fDoc.Text;
  fClient.Input.Write(fInputSource[1], fInputSource.length);
  fClient.CloseInput;
end;

procedure TDcdWrapper.addImportFolder(const folder: string);
begin
  if not fAvailable or not fServerListening or fImportCache.contains(folder) then
    exit;

  fImportCache.insert(folder);
  fClient.Parameters.Clear;
  tryAddTcpParams;
  fClient.Parameters.Add('-I' + folder);
  fClient.Execute;
  while fClient.Running do ;
end;

procedure TDcdWrapper.addImportFolders(const folders: TStrings);
var
  i: string;
  c: integer;
begin
  if not fAvailable or not fServerListening then
    exit;

  fClient.Parameters.Clear;
  tryAddTcpParams;
  c := folders.Count;
  for i in folders do
  begin
    if fImportCache.contains(i) then
      continue;
    fImportCache.insert(i);
    fClient.Parameters.Add('-I' + i);
    dec(c);
  end;
  if c <> folders.Count then
  begin
    fClient.Execute;
    while fClient.Running do ;
  end;
end;

procedure TDcdWrapper.remImportFolder(const folder: string);
begin
  if not fCanRemove then
    exit;
  if not fAvailable or not fServerListening or not fImportCache.contains(folder) then
    exit;

  fImportCache.delete(folder);
  fClient.Parameters.Clear;
  tryAddTcpParams;
  fClient.Parameters.Add('-R' + folder);
  fClient.Execute;
  while fClient.Running do ;
end;

procedure TDcdWrapper.remImportFolders(const folders: TStrings);
var
  i: string;
  c: integer;
begin
  if not fCanRemove then
    exit;
  if not fAvailable or not fServerListening then
    exit;

  fClient.Parameters.Clear;
  tryAddTcpParams;
  c := folders.Count;
  for i in folders do
  begin
    if not fImportCache.contains(i) then
      continue;
    fImportCache.delete(i);
    fClient.Parameters.Add('-R' + i);
    dec(c);
  end;
  if c <> folders.Count then
  begin
    fClient.Execute;
    while fClient.Running do ;
  end;
end;

procedure TDcdWrapper.getCallTip(out tips: string);
begin
  if not fAvailable or not fServerListening or fDoc.isNil then
    exit;

  terminateClient;
  fClient.Parameters.Clear;
  tryAddTcpParams;
  fClient.Parameters.Add('-c');
  fClient.Parameters.Add(intToStr(fDoc.SelStart - 1));
  fClient.Execute;
  writeSourceToInput;

  fTempLines.Clear;
  processOutputToStrings(fClient, fTempLines);
  while fClient.Running do ;
  if fTempLines.Count = 0 then
  begin
    updateServerlistening;
    exit;
  end;
  if not (fTempLines[0] = 'calltips') then exit;

  fTempLines.Delete(0);
  tips := fTempLines.Text;
  {$IFDEF WINDOWS}
  tips := tips[1..tips.length-2];
  {$ELSE}
  tips := tips[1..tips.length-1];
  {$ENDIF}
end;

function compareknd(List: TStringList; Index1, Index2: Integer): Integer;
var
  k1, k2: byte;
begin
  k1 := Byte(PtrUint(List.Objects[Index1]));
  k2 := Byte(PtrUint(List.Objects[Index2]));
  if k1 > k2 then
    result := 1
  else if k1 < k2 then
    result := -1
  else
    result := CompareStr(list[Index1], list[Index2]);
end;

procedure TDcdWrapper.getComplAtCursor(list: TStringList);
var
  i: Integer;
  kind: Char;
  item: string;
  kindObj: TObject = nil;
begin
  if not fAvailable or not fServerListening or fDoc.isNil then
    exit;

  terminateClient;
  fClient.Parameters.Clear;
  tryAddTcpParams;
  fClient.Parameters.Add('-c');
  fClient.Parameters.Add(intToStr(fDoc.SelStart - 1));
  fClient.Execute;
  writeSourceToInput;

  fTempLines.Clear;
  processOutputToStrings(fClient, fTempLines);
  while fClient.Running do ;
  if fTempLines.Count = 0 then
  begin
    updateServerlistening;
    exit;
  end;
  if not (fTempLines[0] = 'identifiers') then exit;

  list.Clear;
  for i := 1 to fTempLines.Count-1 do
  begin
    item := fTempLines[i];
    kind := item[item.length];
    setLength(item, item.length-2);
    case kind of
      'c': kindObj := TObject(PtrUint(dckClass));
      'i': kindObj := TObject(PtrUint(dckInterface));
      's': kindObj := TObject(PtrUint(dckStruct));
      'u': kindObj := TObject(PtrUint(dckUnion));
      'v': kindObj := TObject(PtrUint(dckVariable));
      'm': kindObj := TObject(PtrUint(dckMember));
      'k': kindObj := TObject(PtrUint(dckReserved));
      'f': kindObj := TObject(PtrUint(dckFunction));
      'g': kindObj := TObject(PtrUint(dckEnum));
      'e': kindObj := TObject(PtrUint(dckEnum_member));
      'P': kindObj := TObject(PtrUint(dckPackage));
      'M': kindObj := TObject(PtrUint(dckModule));
      'a': kindObj := TObject(PtrUint(dckArray));
      'A': kindObj := TObject(PtrUint(dckAA));
      'l': kindObj := TObject(PtrUint(dckAlias));
      't': kindObj := TObject(PtrUint(dckTemplate));
      'T': kindObj := TObject(PtrUint(dckMixin));
      // internal DCD stuff, Should not to happen...report bug if it does.
      '*', '?': continue;
    end;
    list.AddObject(item, kindObj);
  end;
  //list.CustomSort(@compareknd);
end;

procedure TDcdWrapper.getDdocFromCursor(out comment: string);
var
  i: Integer;
  len: Integer;
  str: string;
begin
  if not fAvailable or not fServerListening or fDoc.isNil then
    exit;

  i := fDoc.MouseBytePosition;
  if i = 0 then exit;

  terminateClient;
  fClient.Parameters.Clear;
  tryAddTcpParams;
  fClient.Parameters.Add('-d');
  fClient.Parameters.Add('-c');
  fClient.Parameters.Add(intToStr(i - 1));
  fClient.Execute;
  writeSourceToInput;

  comment := '';
  fTempLines.Clear;
  processOutputToStrings(fClient, fTempLines);
  while fClient.Running do ;
  len := fTempLines.Count-1;
  if len = -1 then
    updateServerlistening;
  for i := 0 to len do
  begin
    str := fTempLines[i];
    with TStringRange.create(str) do while not empty do
    begin
      comment += takeUntil('\').yield;
      if startsWith('\\') then
      begin
        comment += '\';
        popFrontN(2);
      end
      else if startsWith('\n') then
      begin
        comment += LineEnding;
        popFrontN(2);
      end
    end;
    if i <> len then
      comment += LineEnding + LineEnding;
  end;
end;

procedure TDcdWrapper.getDeclFromCursor(out fname: string; out position: Integer);
var
   i: Integer;
   str, loc: string;
begin
  if not fAvailable or not fServerListening or fDoc.isNil then
    exit;

  terminateClient;
  fClient.Parameters.Clear;
  tryAddTcpParams;
  fClient.Parameters.Add('-l');
  fClient.Parameters.Add('-c');
  fClient.Parameters.Add(intToStr(fDoc.SelStart));
  fClient.Execute;
  writeSourceToInput;

  fTempLines.Clear;
  processOutputToStrings(fClient, fTempLines);
  while fClient.Running do ;
  if fTempLines.Count > 0 then
  begin
    str := fTempLines[0];
    if str.isNotEmpty then
    begin
      i := Pos(#9, str);
      if i = -1 then
        exit;
      loc := str[i+1..str.length];
      fname := TrimFilename(str[1..i-1]);
      loc := ReplaceStr(loc, LineEnding, '');
      position := strToIntDef(loc, -1);
    end;
  end
  else updateServerlistening;
end;

procedure TDcdWrapper.getLocalSymbolUsageFromCursor(var locs: TIntOpenArray);
var
   i: Integer;
   str: string;
begin
  if not fAvailable or not fServerListening or fDoc.isNil then
    exit;

  terminateClient;
  fClient.Parameters.Clear;
  tryAddTcpParams;
  fClient.Parameters.Add('-u');
  fClient.Parameters.Add('-c');
  fClient.Parameters.Add(intToStr(fDoc.SelStart - 1));
  fClient.Execute;
  writeSourceToInput;

  setLength(locs, 0);
  fTempLines.Clear;
  processOutputToStrings(fClient, fTempLines);
  while fClient.Running do ;
  if fTempLines.Count < 2 then
    exit;
  str := fTempLines[0];
  // symbol is not in current module, too complex for now
  if str[1..5] <> 'stdin' then
    exit;

  setLength(locs, fTempLines.count-1);
  for i:= 1 to fTempLines.count-1 do
    locs[i-1] := StrToIntDef(fTempLines[i], -1);
end;
{$ENDREGION}

function DCDWrapper: TDcdWrapper;
begin
  if fDcdWrapper.isNil then
    fDcdWrapper := TDcdWrapper.create(nil);
  exit(fDcdWrapper);
end;

finalization
  DcdWrapper.Free;
end.
