Synedit syntax-highlighter for HL7 v2.x messages

1k views Asked by At

I am looking at contributing to the Delphi SynEdit project with a syntax-highlighter for the Health Level 7 (HL7) v2 messaging Standard. I have no experience of creating a highlighter from scratch and there are two quirks that I have stumbled upon that differ from existing highlighters:

  • Fixed position keywords - first three characters of every line
  • Delimiters are defined in the begining of the message

Is there anyone out there who has any SynEdit experice with HL7 or similar syntaxes e.g. Edifact, X12?

Prototype

I've created a crude prototype using the OnPaintTransient event-handler which in fact works better than I anticipated :-) Basically it does the following:

  • Highlight each Segment ID navy-blue - first three characters on ever line. No checking done if the value is a valid segment.
  • Highlight all field delimiters grey - defined as the fourth character in MSH segment
  • Highlight all other delimiters blue - defined in the field called Encoding Characters, which is the first field after the MSH segment ID.
  • The delimiter values used in the MSH segment are the delimiter values used throughout the entire message.
  • skip highlighting if underlying text is selected - looks prettier in my implementation.

Below is a screen-dump of the results when inserting the example message found at Wikipedia http://en.wikipedia.org/wiki/Health_Level_7 into a TSynMemo component.

Screen-dump SynEdit HL7 syntax-highlighting

Code OnPaintTransient

procedure TFormMain.SynMemoMsgPaintTransient(Sender: TObject; Canvas: TCanvas;
  TransientType: TTransientType);
var
  i, j: Integer;

  DP: TDisplayCoord;
  SelStartCoord, SelEndCoord, BC : TBufferCoord;
  Pt: TPoint;

  FieldDelimiter : char;  // MSH|
  Delimiters : string;    // All message delimiters (including field delimiter)
  IsSelected : boolean;
begin
  //Avoid drawing twice - Only enter if TransientType = ttAfter.
  if TransientType = ttBefore then exit;

  //Exit if no text
  if SynMemoMsg.Lines.Count = 0 then exit;

  //Exit if message does not start with MSH (Message header segment)
  if not AnsiStartsText('MSH', SynMemoMsg.Lines[0]) then exit;

  //Get the message's delimiters specified as the characters directly after MSH
  FieldDelimiter := Copy(SynMemoMsg.Lines[0], 4, 1)[1];
  Delimiters :=  Copy(SynMemoMsg.Lines[0], 4, 5);

  //Find out if any text is selected by the user - we will exclude this text from highlighting
  SelStartCoord := SynMemoMsg.CharIndexToRowCol(SynMemoMsg.SelStart);
  SelEndCoord := SynMemoMsg.CharIndexToRowCol(SynMemoMsg.SelEnd);

  //parse evry visible line
  for i := SynMemoMsg.TopLine to ((SynMemoMsg.TopLine + SynMemoMsg.LinesInWindow )-1) do
  begin
    //Highlight Segment ID, i.e. in this implementation the first 3 chars in each line
    BC.Char := 1;
    BC.Line := i;

    //If whole line is selected then continue to next line without highlighting current
    if (SelStartCoord.Line < BC.Line) and (SelEndCoord.Line > BC.Line) then continue;

    DP := SynMemoMsg.BufferToDisplayPos(BC);
    Pt := SynMemoMsg.RowColumnToPixels(DP);

    if ((SelStartCoord.Line = BC.Line) and (SelStartCoord.Char > 3))
       or ((SelStartCoord.Line <> BC.Line) and (SelEndCoord.Line <> BC.Line))
       or (SynMemoMsg.SelLength = 0) then
    begin
      Canvas.Font.Color := clNavy;
      Canvas.Font.Style := [fsBold];
      Canvas.TextOut (Pt.X - 1, Pt.Y, Copy(SynMemoMsg.Lines[i - 1], 1, 3)); //Move the Bold text one pixel left to get space i.e. Pt.X - 1)
    end;

    //Highlight Delimiters - parse each charachter and check if delimiter and not selected
    for j := 4 to Length(SynMemoMsg.Lines[i - 1]) do
    begin
      if IsDelimiter(Delimiters, SynMemoMsg.Lines[i - 1], j) then
      begin
        BC.Char := j;
        BC.Line := i;

        //Don't highlight delimiter if selected
        if (SynMemoMsg.SelLength > 0) and ((SelStartCoord.Line = BC.Line)or (SelEndCoord.Line = BC.Line)) then
        begin

          if (SelStartCoord.Line = BC.Line) and (SelEndCoord.Line = BC.Line) then
            IsSelected := (SelStartCoord.Char <= BC.Char) and (SelEndCoord.Char > BC.Char)
          else if (SelStartCoord.Line = BC.Line) then
            IsSelected := SelStartCoord.Char <= BC.Char
          else if (SelEndCoord.Line = BC.Line) then
            IsSelected := SelEndCoord.Char > BC.Char;
        end
        else
          IsSelected := false;

        if not IsSelected then begin
          DP := SynMemoMsg.BufferToDisplayPos(BC);
          Pt := SynMemoMsg.RowColumnToPixels(DP);

          if FieldDelimiter = SynMemoMsg.Lines[i - 1][j] then
            Canvas.Font.Color := clGray
          else
            Canvas.Font.Color := clBlue;

          Canvas.TextOut (Pt.X, Pt.Y, Copy(SynMemoMsg.Lines[i - 1], j, 1));
        end;
      end;
    end;
  end;
end;
1

There are 1 answers

1
Lars On BEST ANSWER

Well I ended up making my own SynEdit syntax-highlighter for HL7 v2.x messaging. It may not have all the bells and whistles but it’s a good start. My implementation uses Delphi XE3.

Usage:

  1. Copy the SynHighlighterHL7.pas unit found below to your synedit project source folder.
  2. Add SynHighlighterHL7.pas to your project and to the Uses clause.
  3. Add a TSynEdit or TSynMemo component to a form
  4. Add the following code to the form's OnCreate event handler:

Code:

fSynHL7Syn := TSynHL7Syn.Create(Self);
SynMemoMsg.Highlighter := fSynHL7Syn;

SynHighlighterHL7.pas unit:

unit SynHighlighterHL7;

{$I SynEdit.inc}

interface

uses
    Classes,
    Graphics,
    StrUtils,
    SynEditTypes,
    SynEditHighlighter,
    SynUnicode;

const
  DEF_FIELD_DELIM   = '|'; //Filed seperator
  DEF_COMP_DELIM    = '^'; //Component seperator
  DEF_SUBCOMP_DELIM = '&'; //Sub-component seperator
  DEF_ESC_DELIM     = '\'; //Escape seperator
  DEF_REP_DELIM     = '~'; //Repetition seperator

type
  TtkTokenKind = (tkSegmentID, tkFieldDelim, tkCompDelim, tkSubCompDelim,
                  tkEscDelim, tkRepDelim, tkText, tkSpace, tkNull, tkUnknown);

  //Keeps track if we're in a message with properly defined delimiters
  TRangeState = (rsUnknown, rsMshDelim, rsDefDelim);

type
  TSynHL7Syn = class(TSynCustomHighlighter)
  private
    fRange : TRangeState;
    fFieldDelim : char;
    fCompDelim : char;
    fSubCompDelim : char;
    fEscDelim : char;
    fRepDelim : char;

    FTokenID: TtkTokenKind;

    fSegmentIDAttri: TSynHighlighterAttributes;
    fFieldDelimAttri: TSynHighlighterAttributes;
    fCompDelimAttri: TSynHighlighterAttributes;
    fSubCompDelimAttri: TSynHighlighterAttributes;
    fEscDelimAttri: TSynHighlighterAttributes;
    fRepDelimAttri: TSynHighlighterAttributes;
    fUnknownAttri: TSynHighlighterAttributes;
    fSpaceAttri : TSynHighlighterAttributes;
    fTextAttri: TSynHighlighterAttributes;
    procedure SegmentIDProc;
    procedure UnknownProc;

    procedure CRProc;
    procedure TextProc;
    procedure LFProc;
    procedure NullProc;
    procedure SpaceProc;
    procedure FieldDelimProc;
    procedure CompDelimProc;
    procedure EscDelimProc;
    procedure RepDelimProc;
    procedure SubCompDelimProc;
    procedure SetRangeState(const Line: string);
  protected
    function GetSampleSource: UnicodeString; override;
    function IsFilterStored: Boolean; override;
  public
    function GetRange: Pointer; override;
    procedure ResetRange; override;
    procedure SetRange(Value: Pointer); override;

    class function GetLanguageName: string; override;
    class function GetFriendlyLanguageName: UnicodeString; override;
  public
    constructor Create(AOwner: TComponent); override;
    function GetDefaultAttribute(Index: integer): TSynHighlighterAttributes;
      override;
    function GetEol: Boolean; override;
    function GetTokenID: TtkTokenKind;
    function GetTokenAttribute: TSynHighlighterAttributes; override;
    function GetTokenKind: integer; override;
    procedure Next; override;
  published
    property SegmentIDAttri: TSynHighlighterAttributes read fSegmentIDAttri
      write fSegmentIDAttri;
    property TextAttri: TSynHighlighterAttributes read fTextAttri
      write fTextAttri;

  end;

implementation

uses
  SynEditStrConst;

constructor TSynHL7Syn.Create(AOwner: TComponent);
begin
  inherited Create(AOwner);

  fCaseSensitive := true;

  fSegmentIDAttri := TSynHighlighterAttributes.Create('Seg ID', 'Segment ID');
  fSegmentIDAttri.Style := [fsBold];
  fSegmentIDAttri.Foreground := clNavy;
  AddAttribute(fSegmentIDAttri);


  fFieldDelimAttri := TSynHighlighterAttributes.Create('Field Sep', 'Field Seperator (|)');
  fFieldDelimAttri.Foreground := clGray;
  AddAttribute(fFieldDelimAttri);

  fCompDelimAttri := TSynHighlighterAttributes.Create('Comp Sep', 'Component Seperator (^)');
  fCompDelimAttri.Foreground := clBlue;
  AddAttribute(fCompDelimAttri);

  fSubCompDelimAttri := TSynHighlighterAttributes.Create('Sub-Comp Sep', 'Sub-Component Seperator (&)');
  fSubCompDelimAttri.Foreground := clBlue;
  AddAttribute(fSubCompDelimAttri);

  fRepDelimAttri := TSynHighlighterAttributes.Create('Rep Sep', 'Repeat Seperator (&)');
  fRepDelimAttri.Foreground := clBlue;
  AddAttribute(fRepDelimAttri);

  fEscDelimAttri := TSynHighlighterAttributes.Create('Esc Sep', 'Escape Seperator (\)');
  fEscDelimAttri.Style := [fsBold];
  fEscDelimAttri.Foreground := clGreen;
  AddAttribute(fEscDelimAttri);

  fUnknownAttri := TSynHighlighterAttributes.Create('Unknown', 'Non HL7 message i.e arbitary text');
  fUnknownAttri.Style := [fsItalic];
  fUnknownAttri.Foreground := clRed;
  AddAttribute(fUnknownAttri);

  fTextAttri := TSynHighlighterAttributes.Create(SYNS_AttrText, SYNS_FriendlyAttrText);
  AddAttribute(fTextAttri);

  fSpaceAttri := TSynHighlighterAttributes.Create(SYNS_AttrSpace, SYNS_FriendlyAttrSpace);
  AddAttribute(fSpaceAttri);
  SetAttributesOnChange(DefHighlightChange);

  fDefaultFilter := SYNS_FilterINI;
end; { Create }

procedure TSynHL7Syn.FieldDelimProc;
begin
  inc(Run);
  fTokenID := tkFieldDelim;
end;

procedure TSynHL7Syn.CompDelimProc;
begin
  inc(Run);
  fTokenID := tkCompDelim;
end;

procedure TSynHL7Syn.SubCompDelimProc;
begin
  inc(Run);
  fTokenID := tkSubCompDelim;
end;

procedure TSynHL7Syn.EscDelimProc;
begin
  fTokenID := tkEscDelim;

  //If current position is not the first MSH field then expand token untill
  //closing Escape delimiter is found on current line
  if not((Run = 6) and StartsStr('MSH', fLine)) then begin
     inc(run);
     while (FLine[Run] <> fEscDelim) and (FLine[Run] <> #0) do
       inc(Run);

  end;
  if FLine[Run] <> #0 then
     inc(Run);

end;

procedure TSynHL7Syn.RepDelimProc;
begin
  inc(Run);
  fTokenID := tkRepDelim;
end;

procedure TSynHL7Syn.SetRangeState(const Line : string);
  function IsValidSegmentIDChar(c : char): Boolean;
  begin
    case c of
      'A'..'Z', '0'..'9':
        Result := True;
      else
        Result := False;
    end;
  end;
var SegID : string;
    OK : boolean;
    i : integer;
begin
  //Decide if valid segment or arbitary text
  if AnsiStartsStr('MSH', Line) and (Length(Line) > 8) then begin
    fRange := rsMshDelim;

    fFieldDelim   := Line[4];
    fCompDelim    := Line[5];
    fRepDelim     := Line[6];

    //If no escape characters are used in a message, this character may be omitted.
    //However, it must be present if subcomponents are used in the message.
    if Line[7] <> fFieldDelim then
       fEscDelim     := Line[7]
    else
       fEscDelim := DEF_ESC_DELIM;

    //If there are no subcomponents in message then this seperator may not be present (use default then)
    if Line[8] <> fFieldDelim then
       fSubCompDelim     := Line[8]
    else
       fEscDelim := DEF_SUBCOMP_DELIM;
  end
  else begin

    SegID := Copy(FLine, run + 1, 3);
    OK := Length(SegID) = 3;
    for i := 1 to Length(SegID) do
        OK := OK and IsValidSegmentIDChar(SegID[i]);

    if OK then begin
       case fRange of
          rsUnknown : if (Copy(Line, 4, 1) = '|') then fRange := rsDefDelim;
          rsMshDelim : if (Copy(Line, 4, 1) <> fFieldDelim) then fRange := rsUnknown;
          rsDefDelim : if (Copy(Line, 4, 1) <> '|') then fRange := rsUnknown;
       end;
    end
    else
      fRange := rsUnknown;
  end;
end;

procedure TSynHL7Syn.ResetRange;
begin
  fRange:= rsUnknown;
end;

procedure TSynHL7Syn.SegmentIDProc;
  function IsValidSegmentIDChar(c : char): Boolean;
  begin
    case c of
      'A'..'Z', '0'..'9':
        Result := True;
      else
        Result := False;
    end;
  end;

var OK : boolean;
    SegID : String;
    i : integer;
begin
  // if it is not column 0-2 mark as tkText and get out of here
  if Run > 0 then
  begin
    fTokenID := tkText;
    inc(Run);
    Exit;
  end;

  case fRange of
    rsMshDelim, rsDefDelim : begin
                               fTokenID := tkSegmentID;
                               Run := 3;
                             end;
    rsUnknown : begin
                  fTokenID := tkUnknown;
                  Inc(Run);
                end;
  end;
end;

procedure TSynHL7Syn.CRProc;
begin
  fTokenID := tkSpace;
  case FLine[Run + 1] of
    #10: inc(Run, 2);
    else inc(Run);
  end;
end;

procedure TSynHL7Syn.TextProc;

  function IsTextChar: Boolean;
  begin
    case fLine[Run] of
      'a'..'z', 'A'..'Z', '0'..'9':
        Result := True;
      else
        Result := False;
    end;
  end;

begin
  if Run = 0 then
    SegmentIDProc
  else
  begin
    fTokenID := tkText;
    inc(Run);
    while FLine[Run] <> #0 do
      if IsTextChar then
        inc(Run)
      else
        break;
  end;
end;

procedure TSynHL7Syn.UnknownProc;
begin
  if Run = 0 then
    Self.SetRangeState(fLine);

  // this is column 0 ok it is a comment
  fTokenID := tkUnknown;
  inc(Run);
  while FLine[Run] <> #0 do
    case FLine[Run] of
      #10: break;
      #13: break;
      else inc(Run);
    end;
end;

procedure TSynHL7Syn.LFProc;
begin
  fTokenID := tkSpace;
  inc(Run);
end;


procedure TSynHL7Syn.SetRange(Value: Pointer);
begin
  fRange := TRangeState(Value);
end;

procedure TSynHL7Syn.SpaceProc;
begin
  inc(Run);
  fTokenID := tkSpace;
  while (FLine[Run] <= #32) and not IsLineEnd(Run) do inc(Run);
end;

procedure TSynHL7Syn.Next;
begin
  //Decide range state by checking first char in line
  fTokenPos := Run;
  if Run = 0 then SetRangeState(fLine);

  case fRange of
    rsUnknown :  case fLine[Run] of
                    #0: NullProc;
                    #10: LFProc;
                    #13: CRProc;
                 else
                    UnknownProc;
                 end;

    rsMshDelim : begin
                   if fLine[Run] = Self.fFieldDelim then
                      FieldDelimProc
                   else if fLine[Run] = Self.fCompDelim then
                      CompDelimProc
                   else if fLine[Run] = Self.fSubCompDelim then
                      SubCompDelimProc
                   else if fLine[Run] = Self.fEscDelim then
                      EscDelimProc
                   else if fLine[Run] = Self.fRepDelim then
                      RepDelimProc
                   else begin

                      case fLine[Run] of
                                      #0: NullProc;
                                      #10: LFProc;
                                      #13: CRProc;
                                      #1..#9, #11, #12, #14..#32: SpaceProc;
                                      else TextProc;
                                    end;
                    end
                  end;
    rsDefDelim : case fLine[Run] of
                        #0: NullProc;
                        #10: LFProc;
                        #13: CRProc;
                        #1..#9, #11, #12, #14..#32: SpaceProc;
                        DEF_FIELD_DELIM : FieldDelimProc;
                        DEF_COMP_DELIM : CompDelimProc;
                        DEF_SUBCOMP_DELIM : SubCompDelimProc;
                        DEF_ESC_DELIM : EscDelimProc;
                        DEF_REP_DELIM : RepDelimProc;
                        else TextProc;
                      end;
  end;
  inherited;
end;

procedure TSynHL7Syn.NullProc;
begin
  fTokenID := tkNull;
  inc(Run);
end;

function TSynHL7Syn.GetDefaultAttribute(Index: integer): TSynHighlighterAttributes;
begin
  case Index of
    SYN_ATTR_WHITESPACE: Result := fSpaceAttri;
  else
    Result := nil;
  end;
end;

function TSynHL7Syn.GetEol: Boolean;
begin
  Result := Run = fLineLen + 1;
end;

function TSynHL7Syn.GetTokenID: TtkTokenKind;
begin
  Result := fTokenId;
end;

function TSynHL7Syn.GetTokenAttribute: TSynHighlighterAttributes;
begin
  case fTokenID of
    tkSegmentID: Result := fSegmentIDAttri;
    tkFieldDelim: Result := fFieldDelimAttri;
    tkCompDelim: Result := fCompDelimAttri;
    tkSubCompDelim: Result := fSubCompDelimAttri;
    tkRepDelim: Result := fRepDelimAttri;
    tkEscDelim: Result := fEscDelimAttri;

    tkText: Result := fTextAttri;
    tkSpace: Result := fSpaceAttri;
    tkUnknown: Result := fUnknownAttri;
    else Result := nil;
  end;
end;

function TSynHL7Syn.GetTokenKind: integer;
begin
  Result := Ord(fTokenId);
end;

function TSynHL7Syn.IsFilterStored: Boolean;
begin
  Result := fDefaultFilter <> SYNS_FilterINI;
end;

class function TSynHL7Syn.GetLanguageName: string;
begin
  Result := SYNS_LangINI;
end;

function TSynHL7Syn.GetRange: Pointer;
begin
  Result := Pointer(fRange);
end;

function TSynHL7Syn.GetSampleSource: UnicodeString;
begin
  Result := 'MSH|^&\~|123|123'#13#10+
            'PID|123|1234'
end;

{$IFNDEF SYN_CPPB_1}
class function TSynHL7Syn.GetFriendlyLanguageName: UnicodeString;
begin
  Result := SYNS_FriendlyLangINI;
end;

initialization
  RegisterPlaceableHighlighter(TSynHL7Syn);
{$ENDIF}
end.