unit reformatter;

{$mode delphi}{$H+}

interface

uses
  SysUtils, Variants, Classes, Math, Graphics, fpjson, jsonparser,
  Controls, Forms, Dialogs, StdCtrls, ExtCtrls, fphttpclient,
  apphelpers, extra_controls, dbconnection, dbstructures, dbstructures.mysql;

type
  TfrmReformatter = class(TExtForm)
    grpReformatter: TRadioGroup;
    btnCancel: TButton;
    btnOk: TButton;
    lblFormatProviderLink: TLabel;
    chkKeepAsking: TCheckBox;
    procedure btnOkClick(Sender: TObject);
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure grpReformatterClick(Sender: TObject);
    procedure lblFormatProviderLinkClick(Sender: TObject);
  private
    { Private declarations }
    FInputCode, FOutputCode: String;
  public
    { Public declarations }
    function FormatSqlInternal(SQL: String): String;
    function FormatSqlOnlineHeidisql(SQL: String): String;
    function FormatSqlOnlineSqlformatOrg(SQL: String): String;
    property InputCode: String read FInputCode write FInputCode;
    property OutputCode: String read FOutputCode;
  end;

var
  frmReformatter: TfrmReformatter;

implementation

uses main;

{$R *.lfm}


procedure TfrmReformatter.btnOkClick(Sender: TObject);
var
  StartTime: UInt64;
  TimeElapsed: Double;
begin
  Screen.Cursor := crHourGlass;
  try
    StartTime := GetTickCount64;
    case grpReformatter.ItemIndex of
      0: begin
        // Internal
        FOutputCode := FormatSqlInternal(FInputCode);
      end;
      1: begin
        // Online
        FOutputCode := FormatSqlOnlineHeidisql(FInputCode);
      end;
      2: begin
        // sqlformat.org
        FOutputCode := FormatSqlOnlineSqlformatOrg(FInputCode);
      end;
    end;
    // Unify line breaks, so selection end will be correct:
    FOutputCode := fixNewlines(FOutputCode);
    TimeElapsed := GetTickCount64 - StartTime;
    MainForm.LogSQL(f_('Code reformatted in %s, using formatter %s', [FormatTimeNumber(TimeElapsed/1000, True, 3), '#'+grpReformatter.ItemIndex.ToString]));
  except
    on E:Exception do begin
      ErrorDialog(E.ClassName + ': ' + E.Message);
      ModalResult := mrNone;
    end;

  end;

  if not chkKeepAsking.Checked then begin
    // No dialog next time please
    AppSettings.WriteInt(asReformatterNoDialog, grpReformatter.ItemIndex+1);
  end;

  Screen.Cursor := crDefault;
end;

procedure TfrmReformatter.FormCreate(Sender: TObject);
begin
  grpReformatter.Items.Clear;
  grpReformatter.Items.Add(_('Internal'));
  grpReformatter.Items.Add(f_('Online on %s', [APPDOMAIN]));
  grpReformatter.Items.Add(f_('Online on %s', ['sqlformat.org']));
  if AppSettings.ReadInt(asReformatterNoDialog) = 0 then begin
    grpReformatter.ItemIndex := AppSettings.ReadInt(asReformatter);
  end
  else begin
    // asReformatterNoDialog has the same items with an additional "always ask" item at index 0
    grpReformatter.ItemIndex := AppSettings.ReadInt(asReformatterNoDialog) - 1;
  end;
  lblFormatProviderLink.Font.Style := lblFormatProviderLink.Font.Style + [fsUnderline];
end;

procedure TfrmReformatter.FormDestroy(Sender: TObject);
begin
  AppSettings.WriteInt(asReformatter, grpReformatter.ItemIndex);
end;


procedure TfrmReformatter.grpReformatterClick(Sender: TObject);
begin
  case grpReformatter.ItemIndex of
    0: lblFormatProviderLink.Visible := False;
    1: begin
      lblFormatProviderLink.Caption := 'github.com/doctrine/sql-formatter (Jeremy Dorn)';
      lblFormatProviderLink.Hint := 'https://github.com/doctrine/sql-formatter';
      lblFormatProviderLink.Visible := True;
    end;
    2: begin
      lblFormatProviderLink.Caption := 'SQLFormat.org (Andi Albrecht)';
      lblFormatProviderLink.Hint := 'https://sqlformat.org/';
      lblFormatProviderLink.Visible := True;
    end;
  end;
end;

procedure TfrmReformatter.lblFormatProviderLinkClick(Sender: TObject);
begin
  apphelpers.ShellExec(TLabel(Sender).Hint);
end;

function TfrmReformatter.FormatSqlInternal(SQL: String): String;
var
  Conn: TDBConnection;
  SQLFunc: TSQLFunction;
  AllKeywords, ImportantKeywords, PairKeywords: TStringList;
  i, Run, KeywordMaxLen: Integer;
  IsEsc, IsQuote, InComment, InBigComment, InString, InKeyword, InIdent, LastWasComment: Boolean;
  c, p: Char;
  Keyword, PreviousKeyword, TestPair: String;
  Datatypes: TDBDataTypeArray;
const
  WordChars = ['a'..'z', 'A'..'Z', '0'..'9', '_', '.'];
  WhiteSpaces = [#9, #10, #13, #32];
begin
  Conn := MainForm.ActiveConnection;
  // Known SQL keywords, get converted to UPPERCASE
  AllKeywords := TStringList.Create;
  AllKeywords.Text := MySQLKeywords.Text;

  for SQLFunc in Conn.SQLFunctions do begin
    // Leave out operator functions like ">>", and the "X()" function so hex values don't get touched
    if (SQLFunc.Declaration <> '') and (SQLFunc.Name <> 'X') then
      AllKeywords.Add(SQLFunc.Name);
  end;
  Datatypes := Conn.Datatypes;
  for i:=Low(Datatypes) to High(Datatypes) do
    AllKeywords.Add(Datatypes[i].Name);
  KeywordMaxLen := 0;
  for i:=0 to AllKeywords.Count-1 do
    KeywordMaxLen := Max(KeywordMaxLen, Length(AllKeywords[i]));

  // A subset of the above list, each of them will get a linebreak left to it
  ImportantKeywords := Explode(',', 'SELECT,FROM,LEFT,RIGHT,STRAIGHT,NATURAL,INNER,JOIN,WHERE,GROUP,ORDER,HAVING,LIMIT,CREATE,DROP,UPDATE,INSERT,REPLACE,TRUNCATE,DELETE');
  // Keywords which followers should not get separated into a new line
  PairKeywords := Explode(',', 'LEFT,RIGHT,STRAIGHT,NATURAL,INNER,ORDER,GROUP');

  IsEsc := False;
  InComment := False;
  InBigComment := False;
  LastWasComment := False;
  InString := False;
  InIdent := False;
  Run := 1;
  Result := '';
  SQL := SQL + ' ';
  SetLength(Result, Length(SQL)*2);
  Keyword := '';
  PreviousKeyword := '';
  for i:=1 to Length(SQL) do begin
    c := SQL[i]; // Current char
    if i > 1 then p := SQL[i-1] else p := #0; // Previous char

    // Detection logic - where are we?
    if c = '\' then IsEsc := not IsEsc
    else IsEsc := False;
    IsQuote := (c = '''') or (c = '"');
    if c = '`' then InIdent := not InIdent;
    if (not IsEsc) and IsQuote then InString := not InString;
    if (c = '#') or ((c = '-') and (p = '-')) then InComment := True;
    if ((c = #10) or (c = #13)) and InComment then begin
      LastWasComment := True;
      InComment := False;
    end;
    if (c = '*') and (p = '/') and (not InComment) and (not InString) then InBigComment := True;
    if (c = '/') and (p = '*') and (not InComment) and (not InString) then InBigComment := False;
    InKeyword := (not InComment) and (not InBigComment) and (not InString) and (not InIdent) and CharInSet(c, WordChars);

    // Creation of returning text
    if InKeyword then begin
      Keyword := Keyword + c;
    end else begin
      if Keyword <> '' then begin
        if AllKeywords.IndexOf(KeyWord) > -1 then begin
          while (Run > 1) and CharInSet(Result[Run-1], WhiteSpaces) do
            Dec(Run);
          Keyword := UpperCase(Keyword);
          if Run > 1 then begin
            // SELECT, WHERE, JOIN etc. get a new line, but don't separate LEFT JOIN with linebreaks
            if LastWasComment or ((ImportantKeywords.IndexOf(Keyword) > -1) and (PairKeywords.IndexOf(PreviousKeyword) = -1)) then
              Keyword := CRLF + Keyword
            else if (Result[Run-1] <> '(') then
              Keyword := ' ' + Keyword;
          end;
          LastWasComment := False;
        end;
        PreviousKeyword := Trim(Keyword);
        Insert(Keyword, Result, Run);
        Inc(Run, Length(Keyword));
        Keyword := '';
      end;
      if (not InComment) and (not InBigComment) and (not InString) and (not InIdent) then begin
        TestPair := Result[Run-1] + c;
        if (TestPair = '  ') or (TestPair = '( ') then begin
          c := Result[Run-1];
          Dec(Run);
        end;
        if (TestPair = ' )') or (TestPair = ' ,') then
          Dec(Run);
      end;
      Result[Run] := c;
      Inc(Run);
    end;

  end;

  // Cut overlength
  SetLength(Result, Run-2);
end;


function TfrmReformatter.FormatSqlOnlineHeidisql(SQL: String): String;
var
  HttpReq: THttpDownload;
  Parameters, Response: TStringList;
begin
  HttpReq := THttpDownload.Create(Self);
  HttpReq.RequestHeaders.Add('Content-Type: text/plain; charset=utf-8');
  Parameters := TStringList.Create;
  Parameters.AddPair('indent', CodeIndent);
  Parameters.AddPair('input', FInputCode);
  Response := TStringList.Create;
  HttpReq.FormPost(APPDOMAIN + 'sql-formatter.php', Parameters, Response);
  if Response.Count = 0 then
    raise Exception.Create(_('Empty result from online reformatter'));
  Result := Response.Text;
  HttpReq.Free;
end;


function TfrmReformatter.FormatSqlOnlineSqlformatOrg(SQL: String): String;
var
  HttpReq: THttpDownload;
  Parameters, Response: TStringList;
  JsonResponseStr: String;
  JsonParser: TJSONParser;
  JsonTmp: TJSONData;
begin
  HttpReq := THttpDownload.Create(Self);
  HttpReq.RequestHeaders.Add('Content-Type: application/json; charset=utf-8');
  // Parameter documentation: https://sqlformat.org/api/
  Parameters := TStringList.Create;
  Parameters.AddPair('sql', FInputCode);
  Parameters.AddPair('reindent', '1');
  if AppSettings.ReadBool(asTabsToSpaces) then
    Parameters.AddPair('indent_width', AppSettings.ReadInt(asTabWidth).ToString)
  else
    Parameters.AddPair('indent_width', '2');
  Parameters.AddPair('keyword_case', 'upper');
  Response := TStringList.Create;
  HttpReq.FormPost('https://sqlformat.org/api/v1/format', Parameters, Response);
  JsonResponseStr := Response.Text;
  if JsonResponseStr.IsEmpty then
    raise Exception.Create(_('Empty result from online reformatter'));
  JsonParser := TJSONParser.Create(JsonResponseStr, []);
  JsonTmp := JsonParser.Parse;
  Result := JsonTmp.GetPath('result').AsString;
  JsonTmp.Free;
  JsonParser.Free;
  HttpReq.Free;
end;

end.
