unit Keyman.Developer.System.LexicalModelParser;

interface

uses
  System.Classes,
  System.RegularExpressions,

  Keyman.Developer.System.LexicalModelParserTypes;

type
  TLexicalModelParser = class
  private
    FText: TStrings;
    FModified: Boolean;
    FComment: string;
    FFormat: TLexicalModelFormat;
    FWordlists: TStrings;
    FWordBreaker: TLexicalModelWordBreaker;
    FIsEditable: Boolean;
    FInsertAfterWord: string;
    FIsRTL: Boolean;
    FOpenQuote: string;
    FCloseQuote: string;
    FLanguageUsesCasing: Boolean;
    function GetText: string;
    procedure PrepareText;
    procedure SetComment(const Value: string);
    procedure SetFormat(const Value: TLexicalModelFormat);
    procedure SetWordBreaker(const Value: TLexicalModelWordBreaker);
    procedure Parse;
    procedure Modify;
    function ReplaceFormat(const m: TMatch): string;
    function ReplaceWordBreaker(const m: TMatch): string;
    function ReplaceSources(const m: TMatch): string;
    function ReplaceComment(const m: TMatch): string;
    procedure WordlistsChange(Sender: TObject);
    function ReplaceInsertAfterWord(const punctuation: string): string;
    function ReplaceIsRTL(const punctuation: string): string;
    function ReplaceQuotesForKeepSuggestion(const punctuation: string): string;
    procedure SetCloseQuote(const Value: string);
    procedure SetInsertAfterWord(const Value: string);
    procedure SetIsRTL(const Value: Boolean);
    procedure SetOpenQuote(const Value: string);

    function JSONEncodeString(const s: string): string;
    function JSONDecodeString(s: string): string;
    procedure SetLanguageUsesCasing(const Value: Boolean);
    function ReplaceLanguageUsesCasing(const m: TMatch): string;
  public
    constructor Create(Source: string);
    destructor Destroy; override;
    property Text: string read GetText;
    property Format: TLexicalModelFormat read FFormat write SetFormat;
    property WordBreaker: TLexicalModelWordBreaker read FWordBreaker write SetWordBreaker;
    property Wordlists: TStrings read FWordlists;
    property Comment: string read FComment write SetComment;
    property IsEditable: Boolean read FIsEditable;
    property IsRTL: Boolean read FIsRTL write SetIsRTL;
    property LanguageUsesCasing: Boolean read FLanguageUsesCasing write SetLanguageUsesCasing;
    property OpenQuote: string read FOpenQuote write SetOpenQuote;
    property CloseQuote: string read FCloseQuote write SetCloseQuote;
    property InsertAfterWord: string read FInsertAfterWord write SetInsertAfterWord;
  public
    // These defaults are defined in common/lexical-model-types/index.d.ts
    const
      CDefaultInsertAfterWord = ' ';
      CDefaultIsRTL = False;
      CDefaultLanguageUsesCasing = False;
      CDefaultOpenQuote = Char($201c);
      CDefaultCloseQuote = Char($201d);
  end;

implementation

uses
  System.JSON,
  System.SysUtils;

{ TLexicalModelParser }

const
  SComment = '^\s*\/\*(.+?)\*\/';
  SFormat = 'format\s*:\s*([''"])(.+?)(\1)';
  SWordBreaker = 'wordBreaker\s*:\s*([''"])(.+?)(\1)';
  SSources = 'sources\s*:\s*\[(.*?)\]';
  SSource = '\s*([''"])(.+?)(\1)\s*(,?)';

  SLanguageUsesCasingID = 'languageUsesCasing';
  SLanguageUsesCasing = SLanguageUsesCasingID+'\s*:\s*(true|false)';

  SEndOfObject = '\s*};';
  SPunctuationID = 'punctuation';
  SPunctuation =
    ',?\s*'+SPunctuationID+'\s*:\s*{\s*(('+
      '(insertAfterWord\s*:\s*(([''"]).*?(\5)))|'+
      '(isRTL\s*:\s*(true|false))|'+
      '(quotesForKeepSuggestion\s*:\s*{\s*'+
        'open\s*:\s*(([''"]).*?(\11))\s*,\s*'+
        'close\s*:\s*(([''"]).*?(\14))\s*})'+
      '),?\s*)*}';

  // These may delete the entry so we need to take the `,` into account
  SInsertAfterWordID = 'insertAfterWord';
  SInsertAfterWord = ',?\s*'+SInsertAfterWordID+'\s*:\s*(([''"]).*?(\2))';

  SIsRTLID = 'isRTL';
  SIsRTL = ',?\s*'+SIsRTLID+'\s*:\s*(true|false)';

  SQuotesForKeepSuggestionID = 'quotesForKeepSuggestion';
  SQuotesForKeepSuggestion =
    ',?\s*'+SQuotesForKeepSuggestionID+'\s*:\s*{\s*'+
      'open\s*:\s*(([''"]).*?(\2))\s*,\s*'+
      'close\s*:\s*(([''"]).*?(\5))\s*'+
    '}';

constructor TLexicalModelParser.Create(Source: string);
begin
  inherited Create;
  FWordlists := TStringList.Create;
  (FWordlists as TStringList).OnChange := WordlistsChange;
  FText := TStringList.Create;
  FText.Text := Source;
  Parse;
  FModified := False;
end;

destructor TLexicalModelParser.Destroy;
begin
  FreeAndNil(FWordlists);
  FreeAndNil(FText);
  inherited Destroy;
end;

function TLexicalModelParser.GetText: string;
begin
  PrepareText;
  Result := FText.Text;
end;

function TLexicalModelParser.JSONDecodeString(s: string): string;
var
  v: TJSONValue;
begin
  if s[1] = '''' then
  begin
    // Javascript can have ' but JSON only "
    s[1] := '"';
    s[Length(s)] := '"';
  end;
  v := TJSONObject.ParseJSONValue(s);
  try
    if not v.TryGetValue<string>(Result) then
      Result := '';
  finally
    v.Free;
  end;
end;

function TLexicalModelParser.JSONEncodeString(const s: string): string;
var
  j: TJSONString;
begin
  j := TJSONString.Create(s);
  try
    Result := j.ToJSON;
  finally
    j.Free;
  end;
end;

procedure TLexicalModelParser.Modify;
begin
  FModified := True;
end;

procedure TLexicalModelParser.Parse;
var
  re: TRegEx;
  m: TMatch;
  s: string;
begin
  // Long term, we should probably consider using the TypeScript compiler
  // to real-time generate an AST and work from that. But that introduces a
  // layer of glue to the Delphi code that I don't want to get into just now.
  FIsEditable := True;
  FFormat := lmfUnknown;
  FWordBreaker := lmwbUnknown;
  FComment := '';

  FInsertAfterWord := CDefaultInsertAfterWord;
  FIsRTL := CDefaultIsRTL;
  FLanguageUsesCasing := CDefaultLanguageUsesCasing;
  FOpenQuote := CDefaultOpenQuote;
  FCloseQuote := CDefaultCloseQuote;

  s := FText.Text;

  // /* Comment */
  m := TRegEx.Match(s, SComment, [roSingleLine]);
  if m.Success then
    FComment := m.Groups[1].Value;

  // format:
  m := TRegEx.Match(s, SFormat, [roMultiLine]);
  if m.Success then
    FFormat := TLexicalModelParserTypes.FormatFromText(m.Groups[2].Value);

  // wordBreaker:
  m := TRegEx.Match(s, SWordBreaker, [roMultiLine]);
  if m.Success then
    FWordBreaker := TLexicalModelParserTypes.WordBreakerFromText(m.Groups[2].Value);

  // sources:
  FWordlists.Clear;
  m := TRegEx.Match(s, SSources, [roMultiLine]);
  if m.Success then
  begin
    re := TRegEx.Create(SSource, [roMultiLine]);
    m := re.Match(m.Groups[1].Value);
    while m.Success do
    begin
      FWordlists.Add(m.Groups[2].Value);
      m := m.NextMatch;
    end;
  end
  else
    FIsEditable := False;

  // insertAfterWord:
  m := TRegEx.Match(s, SInsertAfterWord, [roMultiLine]);
  if m.Success then
    FInsertAfterWord := JSONDecodeString(m.Groups[1].Value)
  else if TRegEx.Match(s, SInsertAfterWordID, [roMultiLine]).Success then
    FIsEditable := False;


  // isRTL: true
  m := TRegEx.Match(s, SIsRTL, [roMultiLine]);
  if m.Success then
    FIsRTL := m.Groups[1].Value = 'true'
  else if TRegEx.Match(s, SIsRTLID, [roMultiLine]).Success then
    FIsEditable := False;

  // languageUsesCasing: true
  m := TRegEx.Match(s, SLanguageUsesCasing, [roMultiLine]);
  if m.Success then
    FLanguageUsesCasing := m.Groups[1].Value = 'true'
  else if TRegEx.Match(s, SLanguageUsesCasingID, [roMultiLine]).Success then
    FIsEditable := False;

  // quotesForKeepSuggestion: We assume that both open and close are defined,
  // in that order.
  m := TRegEx.Match(s, SQuotesForKeepSuggestion, [roMultiLine]);
  if m.Success then
  begin
    FOpenQuote := JSONDecodeString(m.Groups[1].Value);
    FCloseQuote := JSONDecodeString(m.Groups[4].Value);
  end
  else if TRegEx.Match(s, SQuotesForKeepSuggestionID, [roMultiLine]).Success then
    FIsEditable := False;

  if TRegEx.IsMatch(s, SPunctuationID) and not TRegEx.IsMatch(s, SPunctuation) then
    FIsEditable := False;


  FIsEditable := FIsEditable and
    TRegEx.IsMatch(s, SEndOfObject) and
    (FFormat <> lmfUnknown) and
    (FWordBreaker <> lmwbUnknown);
end;

function TLexicalModelParser.ReplaceComment(const m: TMatch): string;
begin
  if FComment = ''
    then Result := ''
    else Result := '/*'+FComment+'*/';
end;

function TLexicalModelParser.ReplaceFormat(const m: TMatch): string;
begin
  Result := m.Value.Substring(0, m.Groups[2].Index - m.Index) +
    TLexicalModelParserTypes.FormatToText(FFormat) +
    m.Value.Substring(m.Groups[2].Index - m.Index + m.Groups[2].Length);
end;

function TLexicalModelParser.ReplaceWordBreaker(const m: TMatch): string;
begin
  Result := m.Value.Substring(0, m.Groups[2].Index - m.Index) +
    TLexicalModelParserTypes.WordBreakerToText(FWordBreaker) +
    m.Value.Substring(m.Groups[2].Index - m.Index + m.Groups[2].Length);
end;

function TLexicalModelParser.ReplaceSources(const m: TMatch): string;
var
  sources: string;
  source: string;
begin
  sources := '';
  for source in FWordlists do
  begin
    if sources <> '' then
      sources := sources + ', ';
    sources := sources + '''' + source + ''''; // assuming no quote characters
  end;

  Result := m.Value.Substring(0, m.Groups[1].Index - m.Index) +
    sources +
    m.Value.Substring(m.Groups[1].Index - m.Index + m.Groups[1].Length);
end;

function TLexicalModelParser.ReplaceLanguageUsesCasing(const m: TMatch): string;
begin
  Result := m.Value.Substring(0, m.Groups[1].Index - m.Index);
  if FLanguageUsesCasing
    then Result := Result + 'true'
    else Result := Result + 'false';
end;

function TLexicalModelParser.ReplaceInsertAfterWord(const punctuation: string): string;
begin
  if punctuation = ''
    then Result := ''
    else Result := punctuation + ','#13#10;

  Result := Result +
    '    insertAfterWord: '+JSONEncodeString(FInsertAfterWord);
end;

function TLexicalModelParser.ReplaceIsRTL(const punctuation: string): string;
begin
  if punctuation = ''
    then Result := ''
    else Result := punctuation + ','#13#10;

  Result := Result +
    '    isRTL: true';
end;

function TLexicalModelParser.ReplaceQuotesForKeepSuggestion(const punctuation: string): string;
begin
  if punctuation = ''
    then Result := ''
    else Result := punctuation + ','#13#10;

  Result := Result +
    '    quotesForKeepSuggestion: {'#13#10+
    '      open: '+JSONEncodeString(FOpenQuote)+','#13#10+
    '      close: '+JSONEncodeString(FCloseQuote)+#13#10+
    '    }';
end;

procedure TLexicalModelParser.PrepareText;
var
  punctuation, s: string;
  m: TMatch;
begin
  if not FModified then Exit;

  s := FText.Text;

  // Replace tokens
  s := TRegEx.Replace(s, SFormat, ReplaceFormat, [roMultiLine]);
  s := TRegEx.Replace(s, SWordBreaker, ReplaceWordBreaker, [roMultiLine]);
  s := TRegEx.Replace(s, SSources, ReplaceSources, [roMultiLine]);

  if TRegEx.IsMatch(s, SLanguageUsesCasing, [roMultiLine]) then
    s := TRegEx.Replace(s, SLanguageUsesCasing, ReplaceLanguageUsesCasing, [roMultiLine])
  else if FLanguageUsesCasing then
  begin
    m := TRegEx.Match(s, SEndOfObject, [roMultiLine]);
    if m.Success then
      s := s.Substring(0, m.Index - 1) + ','#13#10'  languageUsesCasing: true' + s.Substring(m.Index);
  end;

  s := TRegEx.Replace(s, SPunctuation, '', [roMultiLine]);

  punctuation := '';

  if FInsertAfterWord <> CDefaultInsertAfterWord then
    punctuation := ReplaceInsertAfterWord(punctuation);

  if FIsRTL <> CDefaultIsRTL then
    punctuation := ReplaceIsRTL(punctuation);

  if (FOpenQuote <> CDefaultOpenQuote) or (FCloseQuote <> CDefaultCloseQuote) then
    punctuation := ReplaceQuotesForKeepSuggestion(punctuation);

  if punctuation <> '' then
  begin
    punctuation :=
      ','#13#10+
      '  punctuation: {'+#13#10+
           punctuation + #13#10+
      '  }';
    m := TRegEx.Match(s, SEndOfObject, [roMultiLine]);
    if m.Success then
      s := s.Substring(0, m.Index - 1) + punctuation + s.Substring(m.Index);
  end;

  // Replace, remove or add comment
  if TRegEx.IsMatch(s, SComment, [roSingleLine]) then
    s := TRegEx.Replace(s, SComment, ReplaceComment, [roSingleLine])
  else if FComment <> '' then
    s := '/*'+FComment+'*/'#13#10+s;


  FText.Text := s;
end;

procedure TLexicalModelParser.SetCloseQuote(const Value: string);
begin
  Assert(FIsEditable);
  if FCloseQuote <> Value then
  begin
    FCloseQuote := Value;
    Modify;
  end;
end;

procedure TLexicalModelParser.SetComment(const Value: string);
begin
  Assert(FIsEditable);
  if FComment <> Value then
  begin
    FComment := Value;
    Modify;
  end;
end;

procedure TLexicalModelParser.SetFormat(const Value: TLexicalModelFormat);
begin
  Assert(FIsEditable);
  if FFormat <> Value then
  begin
    FFormat := Value;
    Modify;
  end;
end;

procedure TLexicalModelParser.SetInsertAfterWord(const Value: string);
begin
  Assert(FIsEditable);
  if FInsertAfterWord <> Value then
  begin
    FInsertAfterWord := Value;
    Modify;
  end;
end;

procedure TLexicalModelParser.SetIsRTL(const Value: Boolean);
begin
  Assert(FIsEditable);
  if FIsRTL <> Value then
  begin
    FIsRTL := Value;
    Modify;
  end;
end;

procedure TLexicalModelParser.SetLanguageUsesCasing(const Value: Boolean);
begin
  Assert(FIsEditable);
  if FLanguageUsesCasing <> Value then
  begin
    FLanguageUsesCasing := Value;
    Modify;
  end;
end;

procedure TLexicalModelParser.SetOpenQuote(const Value: string);
begin
  Assert(FIsEditable);
  if FOpenQuote <> Value then
  begin
    FOpenQuote := Value;
    Modify;
  end;
end;

procedure TLexicalModelParser.SetWordBreaker(const Value: TLexicalModelWordBreaker);
begin
  Assert(FIsEditable);
  if FWordBreaker <> Value then
  begin
    FWordBreaker := Value;
    Modify;
  end;
end;

procedure TLexicalModelParser.WordlistsChange(Sender: TObject);
begin
  Modify;
end;

end.
