unit u_tools;

{$I u_defines.inc}

interface

uses
  Classes, SysUtils, LazFileUtils, process, menus, controls, graphics,
  u_common, u_writableComponent, u_interfaces, u_observer, u_inspectors,
  u_synmemo, u_dialogs, u_processes;

type

  TToolItems = class;

  TPipeInputKind = (pikNone, pikEditor, pikSelection, pikLine);

  // Enymerates the events for which a tool can be auto-executed.
  // warning: TToolsEditorWidget uses the enum members as string literals to update the array of tools
  TAutoExecuteEvent = (
    // autoexecute if a project is focused
    aeProjectFocused,
    // autoexecute if a project will close
    aeProjectClosing,
    // autoexecute if a document is focused
    aeDocumentFocused,
    // autoexecute if a document will close
    aeDocumentClosing
  );

  TAutoExecuteEvents = set of TAutoExecuteEvent;

  TToolItem = class(TCollectionItem)
  private
    fToolItems: TToolItems;
    fTerminatedFlag: boolean;
    fNextToolAlias: string;
    fProcess: TDexedProcess;
    fExecutable: TFilename;
    fWorkingDir: TPathname;
    fShowWin: TShowWindowOptions;
    fOpts: TProcessOptions;
    fParameters: TStringList;
    fToolAlias: string;
    fQueryParams: boolean;
    fClearMessages: boolean;
    fOutputToNext: boolean;
    fShortcut: TShortcut;
    fMsgs: IMessagesDisplay;
    fSymStringExpander: ISymStringExpander;
    fPipeInputKind: TPipeInputKind;
    fAskConfirmation: boolean;
    fAutoExecuteEvents: TAutoExecuteEvents;
    fBackColor: TColor;
    procedure setParameters(value: TStringList);
    procedure processOutput(sender: TObject);
    procedure setToolAlias(value: string);
  published
    property toolAlias: string read fToolAlias write setToolAlias;
    property options: TProcessOptions read fOpts write fOpts;
    property executable: TFilename read fExecutable write fExecutable;
    property workingDirectory: TPathname read fWorkingDir write fWorkingDir;
    property parameters: TStringList read fParameters write setParameters;
    property showWindows: TShowWindowOptions read fShowWin write fShowWin;
    property queryParameters: boolean read fQueryParams write fQueryParams;
    property clearMessages: boolean read fClearMessages write fClearMessages;
    property shortcut: TShortcut read fShortcut write fShortcut;
    property nextToolAlias: string read fNextToolAlias write fNextToolAlias;
    property outputToNext: boolean read fOutputToNext write fOutputToNext;
    property pipeInputKind: TPipeInputKind read fPipeInputKind write fPipeInputKind;
    property askConfirmation: boolean read fAskConfirmation write fAskConfirmation;
    property autoExecuteEvents: TAutoExecuteEvents read fAutoExecuteEvents write fAutoExecuteEvents;
    property backgroundColor: TColor read fBackColor write fBackColor default clDefault;
  public
    constructor create(ACollection: TCollection); override;
    destructor destroy; override;
    procedure assign(Source: TPersistent); override;
    procedure execute(previous: TToolItem);
    property process: TDexedProcess read fProcess;
  end;

  TToolItems = class(TCollection)
  public
    function findTool(const value: string): TToolItem;
  end;

  TTools = class(TWritableLfmTextComponent, IEditableShortCut, IDocumentObserver, IProjectObserver, IFPObserver)
  private
    fTools: TToolItems;
    fDoc: TDexedMemo;
    fMenu: TMenuItem;
    fReadOnly: boolean;
    fEventSensitiveTools: array [TAutoExecuteEvent] of array of TToolItem;
    function getTool(index: Integer): TToolItem;
    procedure setTools(value: TToolItems);
    procedure FPOObservedChanged(ASender: TObject; Operation: TFPObservedOperation; Data : Pointer);

    procedure executeToolFromMenu(sender: TObject);

    procedure docNew(document: TDexedMemo);
    procedure docFocused(document: TDexedMemo);
    procedure docChanged(document: TDexedMemo);
    procedure docClosing(document: TDexedMemo);

    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);

    function  scedCount: integer;
    function  scedGetItem(const index: integer): TEditableShortcut;
    procedure scedSetItem(const index: integer; constref item: TEditableShortcut);

  published
    property tools: TToolItems read fTools write setTools;
    property readOnly: boolean read fReadOnly write fReadOnly;
  public
    constructor create(aOwner: TComponent); override;
    destructor destroy; override;

    procedure updateMenu;
    procedure updateEventSensitiveTools;
    function addTool: TToolItem;
    procedure executeTool(tool: TToolItem); overload;
    procedure executeTool(index: Integer); overload;
    property tool[index: integer]: TToolItem read getTool; default;
  end;

var
  CustomTools: TTools;

implementation

uses
  dialogs;

const
  toolsFname = 'tools.txt';

{$REGION TToolItem -------------------------------------------------------------}
function TToolItems.findTool(const value: string): TToolItem;
var
  item: TCollectionItem;
begin
  for item in self do
    if TToolItem(item).toolAlias = value then
      exit(TToolItem(item));
  exit(nil);
end;

constructor TToolItem.create(ACollection: TCollection);
begin
  inherited;
  // TODO-cbugfix: tools are init before symstring, even when order of 'uses' is modified (lpr)
  fSymStringExpander:= getSymStringExpander;
  fMsgs       := getMessageDisplay;
  fToolItems  := TToolItems(ACollection);
  fToolAlias  := format('<tool %d>', [ID]);
  fParameters := TStringList.create;
  fBackColor  := clDefault;
end;

destructor TToolItem.destroy;
begin
  fParameters.Free;
  u_processes.killProcess(fProcess);
  inherited;
end;

procedure TToolItem.assign(Source: TPersistent);
var
  tool: TToolItem;
begin
  // only used to clone a tool: so don't copy everything.
  if Source is TToolItem then
  begin
    tool := TToolItem(Source);
    toolAlias         := tool.toolAlias;
    queryParameters   := tool.queryParameters;
    clearMessages     := tool.clearMessages;
    options           := tool.options;
    executable        := tool.executable;
    workingDirectory  := tool.workingDirectory;
    showWindows       := tool.showWindows;
    pipeInputKind     := tool.pipeInputKind;
    askConfirmation   := tool.askConfirmation;
    parameters.Assign(tool.parameters);
  end
  else inherited;
end;

procedure TToolItem.setParameters(value: TStringList);
begin
  fParameters.Assign(value);
end;

procedure TToolItem.setToolAlias(value: string);
var
  i: integer = 0;
begin
  while fToolItems.findTool(value).isAssigned do
  begin
    value += intToStr(i);
    i += 1;
  end;
  fToolAlias := value;
end;

procedure TToolItem.execute(previous: TToolItem);
var
  arg: string;
  prm: string;
  inp: string;
const
  confSpec = 'Are you sure you want to execute the "%s" tool ?';
begin
  u_processes.killProcess(fProcess);
  fTerminatedFlag := false;

  if fMsgs.isNotAssigned then
    fMsgs := getMessageDisplay;
  if fClearMessages then
    fMsgs.clearByContext(amcMisc);
  if fSymStringExpander.isNotAssigned then
    fSymStringExpander:= getSymStringExpander;

  if askConfirmation and (dlgOkCancel(format(confSpec, [toolAlias])) <> mrOk) then
    exit;

  fProcess := TDexedProcess.Create(nil);
  fProcess.OnReadData:= @processOutput;
  fProcess.OnTerminate:= @processOutput;
  fProcess.Options := fOpts;
  fProcess.Executable := exeFullName(fSymStringExpander.expand(fExecutable));
  fProcess.ShowWindow := fShowWin;
  fProcess.CurrentDirectory := fSymStringExpander.expand(fWorkingDir);
  fProcess.XTermProgram:=consoleProgram;
  for prm in fParameters do if not isStringDisabled(prm) then
    fProcess.Parameters.AddText(fSymStringExpander.expand(prm));
  if fQueryParams then
  begin
    prm := '';
    if InputQuery('Parameters', '', prm) and not prm.isBlank then
    begin
      prm := fSymStringExpander.expand(prm);
      arg := StringReplace(fProcess.Parameters.Text, '<$1>', prm, [rfReplaceAll]);
      if prm.isNotEmpty and (arg = fProcess.Parameters.Text) then
        fProcess.Parameters.AddText(prm)
      else
        fProcess.Parameters.Text := arg;
    end;
  end;

  if fProcess.Executable.fileExists then
  begin
    fProcess.Execute;
    if previous.isAssigned and previous.outputToNext
      and (poUsePipes in previous.Options) and (poUsePipes in Options) then
    begin
      setLength(inp, previous.process.StdoutEx.Size);
      previous.process.StdoutEx.Position:=0;
      previous.process.StdoutEx.Read(inp[1], inp.length);
      fProcess.Input.Write(inp[1], inp.length);
      fProcess.CloseInput;
    end;
  end;
end;

procedure TToolItem.processOutput(sender: TObject);
var
  lst: TStringList;
  str: string;
  nxt: TToolItem;
begin
  if ((not fOutputToNext) or fNextToolAlias.isEmpty) and (poUsePipes in options) then
  begin
    lst := TStringList.Create;
    try
      fProcess.getFullLines(lst);
      fMsgs.beginMessageCall();
      for str in lst do
        fMsgs.message(str, nil, amcMisc, amkAuto);
      fMsgs.endMessageCall();
    finally
      lst.Free;
    end;
  end;
  if (not fProcess.Running) then
  begin
    if fTerminatedFlag then
      exit;
    fTerminatedFlag := true;
    if not fProcess.ExitStatus.equals(0) then
    begin
      fMsgs.message(format('error: the tool (%s) has returned the status %s',
        [fProcess.Executable, prettyReturnStatus(fProcess)]), nil, amcMisc, amkErr);
      if fProcess.autoKilled then
        fMsgs.message(format('the process was autokilled because the size of its output exceeded %d',
          [fProcess.autoKillProcThreshold]), nil, amcMisc, amkWarn);
      u_processes.killProcess(fProcess);
      exit;
    end
    else
    begin
      fMsgs.message(format('the tool (%s) has finished normally',
        [fProcess.Executable]), nil, amcMisc, amkBub);
    end;
    if fNextToolAlias.isNotEmpty then
    begin
      nxt := fToolItems.findTool(fNextToolAlias);
      if nxt.isAssigned then
        nxt.execute(self);
    end;
  end;
end;
{$ENDREGION --------------------------------------------------------------------}

{$REGION Standard Comp/Obj -----------------------------------------------------}
constructor TTools.create(aOwner: TComponent);
var
  fname: string;
begin
  inherited;
  fTools := TToolItems.Create(TToolItem);
  fname := getDocPath + toolsFname;
  if fname.fileExists then
    loadFromFile(fname);
  updateEventSensitiveTools;
  fTools.FPOAttachObserver(self);
  EntitiesConnector.addObserver(self);
end;

destructor TTools.destroy;
begin
  EntitiesConnector.removeObserver(self);

  ForceDirectoriesUTF8(getDocPath);
  saveToFile(getDocPath + toolsFname);
  fTools.Free;
  inherited;
end;
{$ENDREGION}

{$REGION IMainMenuProvider -----------------------------------------------------}
procedure TTools.updateMenu;
var
  mnu: IMainMenu = nil;
  itm: TMenuItem;
  colitm: TToolItem;
  i: integer;
begin
  if fMenu.isNotAssigned then
  begin
    mnu := getMainMenu;
    if mnu.isNotAssigned then
      exit;
    fMenu := mnu.mnuAdd;
    fMenu.Caption:='Custom tools';
  end;
  fMenu.Clear;
  for i := 0 to tools.Count-1 do
  begin
    colitm := tool[i];
    itm := TMenuItem.Create(fMenu);
    itm.ShortCut:= colitm.shortcut;
    itm.Caption := colitm.toolAlias;
    itm.tag := ptrInt(colitm);
    itm.onClick := @executeToolFromMenu;
    fMenu.add(itm);
  end;
end;

procedure TTools.executeToolFromMenu(sender: TObject);
begin
  executeTool(TToolItem(TMenuItem(sender).tag));
end;
{$ENDREGION}

{$REGION IEditableShortCut -----------------------------------------------------}
function TTools.scedCount: integer;
begin
  result := fTools.Count;
end;

function TTools.scedGetItem(const index: integer): TEditableShortcut;
begin
  result.category   := 'Tools';
  result.identifier := tool[index].toolAlias;
  result.shortcut   := tool[index].shortcut;
end;

procedure TTools.scedSetItem(const index: integer; constref item: TEditableShortcut);
begin
  tool[index].shortcut := item.shortcut;
  updateMenu();
end;
{$ENDREGION}

{$REGION IProjectObserver ------------------------------------------------------}
procedure TTools.projNew(project: ICommonProject);
begin
end;

procedure TTools.projChanged(project: ICommonProject);
begin
end;

procedure TTools.projClosing(project: ICommonProject);
var
  t: TToolItem;
begin
  for t in fEventSensitiveTools[aeProjectClosing] do
    t.execute(nil);
end;

procedure TTools.projFocused(project: ICommonProject);
var
  t: TToolItem;
begin
  for t in fEventSensitiveTools[aeProjectFocused] do
    t.execute(nil);
end;

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

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

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

procedure TTools.docFocused(document: TDexedMemo);
var
  t: TToolItem;
begin
  fDoc := document;
  for t in fEventSensitiveTools[aeDocumentFocused] do
    t.execute(nil);
end;

procedure TTools.docChanged(document: TDexedMemo);
begin
end;

procedure TTools.docClosing(document: TDexedMemo);
var
  t: TToolItem;
begin
  if fDoc <> document then
    exit;
  for t in fEventSensitiveTools[aeDocumentClosing] do
    t.execute(nil);
  fDoc := nil;
end;
{$ENDREGION}

{$REGION Tools things ----------------------------------------------------------}
procedure TTools.setTools(value: TToolItems);
begin
  fTools.Assign(value);
  updateEventSensitiveTools();
end;

procedure TTools.FPOObservedChanged(ASender: TObject; Operation: TFPObservedOperation;
  Data : Pointer);
begin
  if operation = ooFree then
    exit;
  updateEventSensitiveTools();
end;

function TTools.getTool(index: Integer): TToolItem;
begin
  result := TToolItem(fTools.Items[index]);
end;

function TTools.addTool: TToolItem;
begin
  result := TToolItem(fTools.Add);
end;

procedure TTools.executeTool(tool: TToolItem);
var
  txt: string = '';
begin
  if tool.isNotAssigned then
    exit;
  tool.execute(nil);
  if (tool.pipeInputKind <> pikNone) and fDoc.isAssigned
    and (poUsePipes in tool.options) and tool.fProcess.Input.isAssigned then
  begin
    case tool.pipeInputKind of
      pikEditor:    txt := fDoc.Text;
      pikLine:      txt := fDoc.LineText;
      pikSelection: txt := fDoc.SelText;
    end;
    if txt.isNotEmpty then
      tool.fProcess.Input.Write(txt[1], txt.length);
    tool.fProcess.CloseInput;
  end;
end;

procedure TTools.executeTool(index: Integer);
begin
  if (index < 0) or (index > fTools.Count-1) then
    exit;
  executeTool(tool[index]);
end;

procedure TTools.updateEventSensitiveTools;
var
  i: integer;
  j: integer;
  e: TAutoExecuteEvent;
  t: TToolItem;
begin
  for e := low(TAutoExecuteEvent) to high(TAutoExecuteEvent) do
    setlength(fEventSensitiveTools[e], 0);
  for i:= 0 to fTools.Count-1 do
  begin
    t := getTool(i);
    for e := low(TAutoExecuteEvent) to high(TAutoExecuteEvent) do
      if e in t.autoExecuteEvents then
    begin
      j := length(fEventSensitiveTools[e]);
      setLength(fEventSensitiveTools[e], j + 1);
      fEventSensitiveTools[e, j] := t;
    end;
  end;
end;
{$ENDREGION}

initialization
  CustomTools := TTools.create(nil);
finalization
  CustomTools.Free;
end.
