unit searchreplace;

{$mode delphi}{$H+}

interface

uses
  SysUtils, Classes, Controls, Forms, Dialogs, StdCtrls,
  ExtCtrls, SynEdit, SynEditTypes, laz.VirtualTrees, RegExpr,
  SynEditMiscClasses, SynEditSearch, extra_controls,
  Menus, texteditor;

type
  TfrmSearchReplace = class(TExtForm)
    btnCancel: TButton;
    btnReplaceAll: TButton;
    lblSearch: TLabel;
    chkReplace: TCheckBox;
    comboSearch: TComboBox;
    comboReplace: TComboBox;
    grpOptions: TGroupBox;
    chkCaseSensitive: TCheckBox;
    chkWholeWords: TCheckBox;
    chkRegularExpression: TCheckBox;
    chkPromptOnReplace: TCheckBox;
    grpDirection: TRadioGroup;
    grpOrigin: TRadioGroup;
    grpScope: TRadioGroup;
    btnOK: TButton;
    lblReplaceHint: TLabel;
    lblSearchIn: TLabel;
    comboSearchIn: TComboBox;
    btnSearchHints: TButton;
    btnReplaceHints: TButton;
    popupSearchHints: TPopupMenu;
    popupReplaceHints: TPopupMenu;
    procedure ValidateControls(Sender: TObject);
    procedure chkReplaceClick(Sender: TObject);
    procedure FormShow(Sender: TObject);
    procedure comboSearchReplaceExit(Sender: TObject);
    procedure FormCreate(Sender: TObject);
    procedure DoSearchReplace(Sender: TObject);
    procedure btnWithDropDownClick(Sender: TObject);
    procedure menuHintClick(Sender: TObject);
    procedure FormClose(Sender: TObject; var Action: TCloseAction);
  private
    { Private declarations }
    procedure DoSearchReplaceText;
    procedure DoSearchReplaceData;
    function GetEditor: TSynEdit;
    function GetGrid: TLazVirtualStringTree;
  public
    { Public declarations }
    Options: TSynSearchOptions;
    property Editor: TSynEdit read GetEditor;
    property Grid: TLazVirtualStringTree read GetGrid;
  end;


implementation

{$R *.lfm}

uses apphelpers, main;


procedure TfrmSearchReplace.FormCreate(Sender: TObject);
  function AddItem(Menu: TPopupMenu; Code, Description, Example: String; IsRegEx: Boolean): TMenuItem;
  begin
    Result := TMenuItem.Create(Menu);
    Result.Caption := Code + ' ' + _(Description);
    Result.Hint := Example;
    Result.OnClick := menuHintClick;
    Result.Tag := Integer(IsRegEx);
    Menu.Items.Add(Result);
  end;
begin
  comboSearch.Text := '';
  comboReplace.Text := '';

  AddItem(popupSearchHints, '^', 'Start of line', '', True);
  AddItem(popupSearchHints, '$', 'End of line', '', True);
  AddItem(popupSearchHints, '.', 'Any character', '', True);
  AddItem(popupSearchHints, '\w', 'Any word char', 'a-z A-Z 0-9 and _', True);
  AddItem(popupSearchHints, '\W', 'Any non-word char', '', True);
  AddItem(popupSearchHints, '\d', 'Digit', '0..9', True);
  AddItem(popupSearchHints, '\D', 'Any char except digits', '', True);
  AddItem(popupSearchHints, '\s', 'Whitespace', 'space, tab, carriage return, line feed, or form feed', True);
  AddItem(popupSearchHints, '\S', 'Any char except whitespaces', '', True);

  AddItem(popupReplaceHints, '\n', 'New line', '', False);
  AddItem(popupReplaceHints, '\t', 'Tabulator', '', False);
  AddItem(popupReplaceHints, '$0', 'Entire matched text', '', True);
  AddItem(popupReplaceHints, '$1', 'Text from first captured group', '', True);
  AddItem(popupReplaceHints, '$2', 'Text from second captured group', '', True);
  AddItem(popupReplaceHints, '$3', 'Text from third captured group', '', True);
  AddItem(popupReplaceHints, '\l', 'Lowercase following char', 'aBCD', True);
  AddItem(popupReplaceHints, '\L', 'Lowercase following block', 'abcd', True);
  AddItem(popupReplaceHints, '\u', 'Uppercase following char', 'Abcd', True);
  AddItem(popupReplaceHints, '\U', 'Uppercase following block', 'ABCD', True);
  AddItem(popupReplaceHints, '\x', 'Hex code follows', '\x85', True);

end;


procedure TfrmSearchReplace.FormShow(Sender: TObject);
var
  SearchText, ItemLabel: String;
  QueryMemo, AnySynMemo, UsedSynMemo: TSynEdit;
  ResultGrid: TLazVirtualStringTree;
  QueryTabOpen, IsGridTextEditor, IsEditorWritable: Boolean;
  ActiveQueryTab: TQueryTab;
begin
  // Populate "Search in" pulldown with grid and editor
  comboSearchIn.Items.Clear;
  ActiveQueryTab := MainForm.QueryTabs.ActiveTab;
  QueryTabOpen := Assigned(ActiveQueryTab);
  SearchText := '';
  UsedSynMemo := nil;

  QueryMemo := MainForm.QueryTabs.ActiveMemo;
  AnySynMemo := MainForm.ActiveSynMemo(True);
  if Assigned(AnySynMemo) then begin
    IsEditorWritable := not AnySynMemo.ReadOnly; // Support views and procedure editors
    IsGridTextEditor := GetParentForm(AnySynMemo) is TfrmTextEditor; // Support grid text editor, read-only or not
    if IsEditorWritable or IsGridTextEditor then
      UsedSynMemo := AnySynMemo;
  end;
  if not Assigned(UsedSynMemo) then begin
    UsedSynMemo := QueryMemo;
  end;
  if Assigned(UsedSynMemo) then begin
    if UsedSynMemo = QueryMemo then
      ItemLabel := _('SQL editor') + ': ' + ActiveQueryTab.TabSheet.Caption
    else
      ItemLabel := GetParentForm(UsedSynMemo).Caption;
    comboSearchIn.Items.AddObject(ItemLabel, UsedSynMemo);
    if UsedSynMemo.Focused then
      comboSearchIn.ItemIndex := comboSearchIn.Items.Count-1;
    if UsedSynMemo.SelAvail then
      SearchText := UsedSynMemo.SelText
    else
      SearchText := UsedSynMemo.GetWordAtRowCol(UsedSynMemo.CaretXY);
  end;

  ResultGrid := MainForm.ActiveGrid;
  if Assigned(ResultGrid) then begin
    if QueryTabOpen then
      ItemLabel := _('Result grid')+': '+ActiveQueryTab.tabsetQuery.Tabs[ActiveQueryTab.tabsetQuery.TabIndex]
    else
      ItemLabel := _('Data Grid');
    comboSearchIn.Items.AddObject(ItemLabel, ResultGrid);
    if ResultGrid.Focused then
      comboSearchIn.ItemIndex := comboSearchIn.Items.Count-1;
    if Assigned(ResultGrid.FocusedNode) then
      SearchText := ResultGrid.Text[ResultGrid.FocusedNode, ResultGrid.FocusedColumn];
  end;

  if (comboSearchIn.ItemIndex = -1) and (comboSearchIn.Items.Count > 0) then begin
    comboSearchIn.ItemIndex := 0;
  end;

  comboSearch.Items.Text := AppSettings.ReadString(asFindDialogSearchHistory);
  comboReplace.Items.Text := AppSettings.ReadString(asFindDialogReplaceHistory);
  // Prefill search editor with selected text
  if SearchText <> '' then
    comboSearch.Text := SearchText
  else if comboSearch.Items.Count > 0 then
    comboSearch.Text := comboSearch.Items[0];
  if comboReplace.Items.Count > 0 then
    comboReplace.Text := comboReplace.Items[0];

  ValidateControls(Sender);
  comboSearch.SetFocus;
end;


procedure TfrmSearchReplace.FormClose(Sender: TObject;
  var Action: TCloseAction);
begin
  AppSettings.WriteString(asFindDialogSearchHistory, comboSearch.Items.Text);
  AppSettings.WriteString(asFindDialogReplaceHistory, comboReplace.Items.Text);
end;


function TfrmSearchReplace.GetEditor: TSynEdit;
begin
  // Return selected target object as editor
  Result := nil;
  if (comboSearchIn.ItemIndex > -1) and (comboSearchIn.Items.Objects[comboSearchIn.ItemIndex] is TSynEdit) then
    Result := comboSearchIn.Items.Objects[comboSearchIn.ItemIndex] as TSynEdit;
end;


function TfrmSearchReplace.GetGrid: TLazVirtualStringTree;
var
  o: TObject;
begin
  // Return selected target object as grid
  Result := nil;
  if comboSearchIn.ItemIndex > -1 then begin
    o := comboSearchIn.Items.Objects[comboSearchIn.ItemIndex];
    if (o <> nil) and (o is TLazVirtualStringTree) then
      Result := o as TLazVirtualStringTree;
  end;
end;


procedure TfrmSearchReplace.menuHintClick(Sender: TObject);
var
  Item: TMenuItem;
  Code: String;
  Combo: TComboBox;
begin
  // Search or replace hint menu item clicked
  Item := Sender as TMenuItem;
  Code := RegExprGetMatch('^(\S+)', StripHotkey(Item.Caption), 1);
  if Item.GetParentMenu = popupSearchHints then
    Combo := comboSearch
  else
    Combo := comboReplace;
  // Do not overwrite user's text
  Combo.SelLength := 0;
  Combo.SelText := Code;
  // Be sure to support regular expression if menu item is a regex pattern
  if (Item.Tag = 1) and (not chkRegularExpression.Checked) then
    chkRegularExpression.Checked := True;
end;


procedure TfrmSearchReplace.btnWithDropDownClick(Sender: TObject);
var
  Menu: TPopupMenu;
begin
  if Sender = btnSearchHints then
    Menu := popupSearchHints
  else
    Menu := popupReplaceHints;
  ShowPopup(Sender as TControl, Menu);
end;


procedure TfrmSearchReplace.chkReplaceClick(Sender: TObject);
begin
  // Jump to replace editor
  ValidateControls(Sender);
  if comboReplace.Enabled then
    ActiveControl := comboReplace;
end;


procedure TfrmSearchReplace.ValidateControls(Sender: TObject);
begin
  // Enable or disable various controls
  comboReplace.Enabled := chkReplace.Checked;
  btnReplaceHints.Enabled := chkReplace.Checked;
  chkPromptOnReplace.Enabled := chkReplace.Checked;
  btnReplaceAll.Enabled := chkReplace.Checked;
  lblReplaceHint.Enabled := chkReplace.Checked;
  if chkReplace.Checked then
    btnOK.Caption := _('Replace')
  else
    btnOK.Caption := _('Find');
end;


procedure TfrmSearchReplace.comboSearchReplaceExit(Sender: TObject);
var
  Combo: TComboBox;
  i, idx: Integer;
begin
  // Store search or replace text history
  Combo := Sender as TComboBox;
  if Combo.Text = '' then
    Exit;
  idx := -1;
  for i:=0 to Combo.Items.Count-1 do begin
    if Combo.Items[i] = Combo.Text then begin
      idx := i;
      break;
    end;
  end;
  if idx > -1 then
    Combo.Items.Move(idx, 0)
  else
    Combo.Items.Insert(0, Combo.Text);
  Combo.Text := Combo.Items[0];
end;


procedure TfrmSearchReplace.DoSearchReplace(Sender: TObject);
begin
  // Set SynEditSearch options once when user hits the dialog button,
  // not when "Find again" action was used
  if (Sender = btnOK) or (Sender = btnReplaceAll) then begin
    Options := [];
    if chkReplace.Checked then Include(Options, ssoReplace);
    if chkCaseSensitive.Checked then Include(Options, ssoMatchCase);
    if chkWholeWords.Checked then Include(Options, ssoWholeWord);
    if chkPromptOnReplace.Checked and chkPromptOnReplace.Enabled then Include(Options, ssoPrompt);
    if grpDirection.ItemIndex = 1 then Include(Options, ssoBackwards);
    if grpOrigin.ItemIndex = 1 then Include(Options, ssoEntireScope);
    if grpScope.ItemIndex = 1 then Include(Options, ssoSelectedOnly);
    if ModalResult = mrAll then Include(Options, ssoReplaceAll);

    // Work around multi line bug in SynEdit
    if (ssoReplaceAll in Options) and (Pos('\n', comboReplace.Text) > 0) then
      Include(Options, ssoBackwards);
  end;

  if Assigned(Editor) then
    DoSearchReplaceText
  else if Assigned(Grid) then
    DoSearchReplaceData
  else
    ErrorDialog(_('No area selected'));
end;


procedure TfrmSearchReplace.DoSearchReplaceText;
var
  Occurences: Integer;
  OldCaretXY: TPoint;
  Replacement: String;
begin
  {if chkRegularExpression.Checked then
    Editor.SearchEngine := SynEditRegexSearch1
  else
    Editor.SearchEngine := SynEditSearch1;}

  OldCaretXY := Editor.CaretXY;
  Replacement := comboReplace.Text;
  Replacement := StringReplace(Replacement, '\n', CRLF, [rfReplaceAll]);
  Replacement := StringReplace(Replacement, '\t', #9, [rfReplaceAll]);

  Editor.BeginUpdate;

  MainForm.ShowStatusMsg(_('Searching ...'));
  Occurences := -1; // So we can test whether an exception happened
  try
    Occurences := Editor.SearchReplace(
      comboSearch.Text,
      Replacement,
      Options
      );
  except
    on E:Exception do begin
      ErrorDialog(E.ClassName + ': ' + E.Message);
      ModalResult := mrNone;
    end;
  end;

  Editor.EndUpdate;
  MainForm.ShowStatusMsg;

  if Occurences > -1 then begin
    if ssoReplaceAll in Options then begin
      MessageDialog(f_('Text "%s" replaced %s times.', [comboSearch.Text, FormatNumber(Occurences)]), mtInformation, [mbOk]);
      if Occurences = 0 then
        ModalResult := mrNone;
    end else begin
      if (OldCaretXY.X = Editor.CaretX) and
        (OldCaretXY.Y = Editor.CaretY) then begin
        MessageDialog(f_('Text "%s" not found.', [comboSearch.Text]), mtInformation, [mbOk]);
        ModalResult := mrNone;
      end;
    end;
  end;
end;


procedure TfrmSearchReplace.DoSearchReplaceData;
var
  Search, Replacement, CellText: String;
  Node: PVirtualNode;
  Column, StartAtCol: TColumnIndex;
  Match, SelectedOnly, Backwards, UseRegEx: Boolean;
  MatchCount, ReplaceCount: Integer;
  rx: TRegExpr;
  Prompt: TModalResult;
  ReplaceFlags: TReplaceFlags;
  NodeSelected: Boolean;
begin
  // Data grid version of DoSearchReplaceText
  MainForm.ShowStatusMsg(_('Searching ...'));

  Search := comboSearch.Text;
  Replacement := comboReplace.Text;
  Replacement := StringReplace(Replacement, '\n', CRLF, [rfReplaceAll]);
  Replacement := StringReplace(Replacement, '\t', #9, [rfReplaceAll]);
  SelectedOnly := ssoSelectedOnly in Options;
  Backwards := ssoBackwards in Options;
  if ssoReplaceAll in Options then
    Prompt := mrYesToAll
  else
    Prompt := mrYes;
  Match := False;
  MatchCount := 0;
  ReplaceCount := 0;
  ReplaceFlags := [rfReplaceAll];
  if not (ssoMatchCase in Options) then
    Include(ReplaceFlags, rfIgnoreCase);
  NodeSelected := True;

  // Init regular expression
  rx := TRegExpr.Create;
  UseRegEx := (ssoWholeWord in Options) or chkRegularExpression.Checked;
  if chkRegularExpression.Checked then
    rx.Expression := Search
  else // Still used for "whole word" search
    rx.Expression := '\b'+QuoteRegExprMetaChars(Search)+'\b';
  rx.ModifierI := not (ssoMatchCase in Options);

  // Set start row and column with regard to "Entire scope" and "Forward/Backward" mode
  if (ssoEntireScope in Options) or (not Assigned(Grid.FocusedNode)) then begin
    if Backwards then
      Node := GetPreviousNode(Grid, nil, SelectedOnly)
    else
      Node := GetNextNode(Grid, nil, SelectedOnly);
    StartAtCol := InvalidColumn;
  end else begin
    Node := Grid.FocusedNode;
    if Backwards then
      StartAtCol := Grid.Header.Columns.GetPreviousVisibleColumn(Grid.FocusedColumn, True)
    else
      StartAtCol := Grid.Header.Columns.GetNextVisibleColumn(Grid.FocusedColumn, True);
    // Advance to next row if focused column is the very last column
    if StartAtCol = InvalidColumn then begin
      if Backwards then
        Node := GetPreviousNode(Grid, Node, SelectedOnly)
      else
        Node := GetNextNode(Grid, Node, SelectedOnly);
    end;
  end;

  while Assigned(Node) do begin
    MainForm.AnyGridEnsureFullRow(Grid, Node);

    // Find the first column
    if StartAtCol > InvalidColumn then begin
      Column := StartAtCol;
      StartAtCol := InvalidColumn;
    end else begin
      if Backwards then
        Column := Grid.Header.Columns.GetLastVisibleColumn(True)
      else
        Column := Grid.Header.Columns.GetFirstVisibleColumn(True);
    end;

    while Column >= 0 do begin
      CellText := Grid.Text[Node, Column];

      if UseRegEx then begin
        Match := rx.Exec(CellText);
      end else begin
        if ssoMatchCase in Options then
          Match := Pos(Search, CellText) > 0
        else
          Match := Pos(LowerCase(Search), LowerCase(CellText)) > 0;
      end;

      if Match then begin
        Inc(MatchCount);

        // Set focus on node and column
        NodeSelected := SelectNode(Grid, Node, not SelectedOnly);
        if not NodeSelected then
          Break;
        Grid.FocusedColumn := Column;

        // Replace logic
        if ssoReplace in Options then begin
          if ssoPrompt in Options then begin
            // Ask user
            Prompt := MessageDialog(f_('Replace this occurrence of "%s"?', [Search]),
              StrEllipsis(CellText, 500),
              mtConfirmation,
              [mbYes, mbYesToAll, mbNo, mbCancel]);
            case Prompt of
              mrYesToAll: begin
                Exclude(Options, ssoPrompt);
                Include(Options, ssoReplaceAll);
              end;
              mrCancel: Exclude(Options, ssoReplaceAll);
            end;
          end;
          if Prompt in [mrYes, mrYesToAll] then begin
            if UseRegEx then
              Grid.Text[Node, Column] := rx.Replace(CellText, Replacement, chkRegularExpression.Checked)
            else
              Grid.Text[Node, Column] := StringReplace(CellText, Search, Replacement, ReplaceFlags);
            Inc(ReplaceCount);
          end;
        end;

        if not (ssoReplaceAll in Options) then
          Break;
      end;

      // Advance to next column
      if Backwards then
        Column := Grid.Header.Columns.GetPreviousVisibleColumn(Column, True)
      else
        Column := Grid.Header.Columns.GetNextVisibleColumn(Column, True);
    end;

    if Match and (not (ssoReplaceAll in Options)) then
      Break;
    if not NodeSelected then
      Break;

    if Backwards then
      Node := GetPreviousNode(Grid, Node, SelectedOnly)
    else
      Node := GetNextNode(Grid, Node, SelectedOnly);
  end;

  if (ssoReplaceAll in Options) and (MatchCount > 0) then begin
    MessageDialog(f_('Text "%s" %d times replaced.', [Search, ReplaceCount]), mtInformation, [mbOk])
  end;
  if MatchCount = 0 then begin
    MessageDialog(f_('Text "%s" not found.', [Search]), mtInformation, [mbOk]);
    ModalResult := mrNone;
  end;

  MainForm.ShowStatusMsg;
end;


end.
