2
0
Michaël Van Canneyt 5 өдөр өмнө
parent
commit
9701f72a3f

+ 2 - 0
packages/fcl-ebnf/Makefile

@@ -0,0 +1,2 @@
+PACKAGE_NAME=fcl-ebnf
+include ../build/Makefile.pkg

+ 62 - 0
packages/fcl-ebnf/examples/readebnf.lpi

@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<CONFIG>
+  <ProjectOptions>
+    <Version Value="12"/>
+    <General>
+      <Flags>
+        <MainUnitHasCreateFormStatements Value="False"/>
+        <MainUnitHasTitleStatement Value="False"/>
+        <MainUnitHasScaledStatement Value="False"/>
+      </Flags>
+      <SessionStorage Value="InProjectDir"/>
+      <Title Value="readebnf"/>
+      <UseAppBundle Value="False"/>
+      <ResourceType Value="res"/>
+    </General>
+    <BuildModes>
+      <Item Name="Default" Default="True"/>
+    </BuildModes>
+    <PublishOptions>
+      <Version Value="2"/>
+      <UseFileFilters Value="True"/>
+    </PublishOptions>
+    <RunParams>
+      <FormatVersion Value="2"/>
+    </RunParams>
+    <Units>
+      <Unit>
+        <Filename Value="readebnf.pp"/>
+        <IsPartOfProject Value="True"/>
+      </Unit>
+    </Units>
+  </ProjectOptions>
+  <CompilerOptions>
+    <Version Value="11"/>
+    <Target>
+      <Filename Value="readebnf"/>
+    </Target>
+    <SearchPaths>
+      <IncludeFiles Value="$(ProjOutDir)"/>
+      <OtherUnitFiles Value="../src"/>
+      <UnitOutputDirectory Value="lib/$(TargetCPU)-$(TargetOS)"/>
+    </SearchPaths>
+    <Linking>
+      <Debugging>
+        <DebugInfoType Value="dsDwarf3"/>
+      </Debugging>
+    </Linking>
+  </CompilerOptions>
+  <Debugging>
+    <Exceptions>
+      <Item>
+        <Name Value="EAbort"/>
+      </Item>
+      <Item>
+        <Name Value="ECodetoolError"/>
+      </Item>
+      <Item>
+        <Name Value="EFOpenError"/>
+      </Item>
+    </Exceptions>
+  </Debugging>
+</CONFIG>

+ 75 - 0
packages/fcl-ebnf/examples/readebnf.pp

@@ -0,0 +1,75 @@
+{
+    This file is part of the Free Component Library (FCL)
+    Copyright (c) 2025 by Michael Van Canneyt ([email protected])
+
+    EBNF grammar Parser demo
+
+    See the file COPYING.FPC, included in this distribution,
+    for details about the copyright.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+ **********************************************************************}
+program readebnf;
+
+{$IFDEF WINDOWS}
+{$APPTYPE CONSOLE}
+{$ENDIF}
+
+uses
+  SysUtils,
+  ebnf.tree,
+  ebnf.parser;
+
+var
+  EBNFSource: string;
+  Parser: TEBNFParser;
+  Grammar: TEBNFGrammar;
+  Rule: TEBNFRule;
+
+begin
+  // Our example
+  if ParamCount=1 then
+    EBNFSource:=GetFileAsString(ParamStr(1))
+  else
+    begin
+    Writeln('Using example source. Provide filename to parse actual file.');
+    Writeln('');
+    EBNFSource :=
+      'program = statement { ";" statement } ;' + sLineBreak +
+      'statement = "IF" expression "THEN" statement [ "ELSE" statement ]' + sLineBreak +
+      '          | identifier "=" expression' + sLineBreak +
+      '          | "PRINT" ( string_literal | identifier ) ;' + sLineBreak +
+      'expression = term { ("+" | "-") term } ;' + sLineBreak +
+      'term = factor { ("*" | "/") factor } ;' + sLineBreak +
+      'factor = identifier | string_literal | number | "(" expression ")" | "?comment?" ;';
+    end;
+
+  Parser := nil;
+  Grammar := nil;
+  try
+    Parser := TEBNFParser.Create(EBNFSource);
+    Grammar := Parser.Parse;
+
+    Writeln('Successfully parsed EBNF grammar:');
+    Writeln('-----------------------------------');
+    Writeln(Grammar.ToString);
+    Writeln('-----------------------------------');
+
+    // demo accessing a specific rule
+    Rule:=Grammar.Rules['program'];
+    if Assigned(Rule)  then
+    begin
+      Writeln('Details for rule "program":');
+      Writeln(Rule.ToString);
+    end;
+
+  except
+    on E: Exception do
+      Writeln('Error: ' + E.Message);
+  end;
+  Grammar.Free;
+  Parser.Free;
+end.

+ 56 - 0
packages/fcl-ebnf/fpmake.pp

@@ -0,0 +1,56 @@
+{$ifndef ALLPACKAGES}
+{$mode objfpc}{$H+}
+program fpmake;
+
+uses {$ifdef unix}cthreads,{$endif} fpmkunit;
+
+Var
+  T : TTarget;
+  P : TPackage;
+begin
+  With Installer do
+    begin
+{$endif ALLPACKAGES}
+
+    P:=AddPackage('fcl-ebnf');
+    P.ShortName:='fclebnf';
+{$ifdef ALLPACKAGES}
+    P.Directory:=ADirectory;
+{$endif ALLPACKAGES}
+    P.Version:='3.3.1';
+    P.Dependencies.Add('fcl-base');
+    P.Dependencies.Add('rtl-objpas');
+    P.Dependencies.Add('fcl-fpcunit');
+    P.Author := 'Michael van Canneyt';
+    P.License := 'LGPL with modification, ';
+    P.HomepageURL := 'www.freepascal.org';
+    P.Email := '';
+    P.Description := 'EBNF grammar parser';
+    P.NeedLibC:= false;
+    P.OSes:=AllOSes-[embedded,msdos,win16,macosclassic,palmos,zxspectrum,msxdos,amstradcpc,sinclairql,human68k,ps1,wasip2];
+    if Defaults.CPU=jvm then
+      P.OSes := P.OSes - [java,android];
+
+    P.SourcePath.Add('src');
+
+    T:=P.Targets.AddUnit('ebnf.tree.pp');
+    
+    T:=P.Targets.AddUnit('ebnf.scanner.pp');
+    T.ResourceStrings:=true;
+
+    T:=P.Targets.AddUnit('ebnf.parser.pp');
+    T.ResourceStrings:=true;
+    with T.Dependencies do
+      begin
+      AddUnit('ebnf.tree');
+      AddUnit('ebnf.scanner');
+      end;
+      
+{$ifndef ALLPACKAGES}
+    Run;
+    end;
+end.
+{$endif ALLPACKAGES}
+
+
+

+ 276 - 0
packages/fcl-ebnf/src/ebnf.parser.pp

@@ -0,0 +1,276 @@
+{
+    This file is part of the Free Component Library (FCL)
+    Copyright (c) 2025 by Michael Van Canneyt ([email protected])
+
+    EBNF grammar Parser
+
+    See the file COPYING.FPC, included in this distribution,
+    for details about the copyright.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+ **********************************************************************}
+
+unit ebnf.parser;
+
+{$mode objfpc}
+{$h+}
+{$modeswitch advancedrecords}
+
+interface
+
+uses
+  {$IFDEF FPC_DOTTEDUNITS}
+  System.SysUtils, System.TypInfo,
+  {$ELSE}
+  sysutils, typinfo,
+  {$ENDIF}
+  ebnf.tree, ebnf.scanner;
+
+type
+  EEBNFParser = class(Exception);
+
+  { TEBNFParser }
+
+  TEBNFParser = class
+  private
+    FScanner: TEBNFScanner;
+    FLastLine: integer;
+    FCurrentToken: TToken;
+    FSource: string;
+  protected
+    procedure Consume(aTokenType: TEBNFTokenType);
+    function ParseGrammar: TEBNFGrammar;
+    function ParseRule: TEBNFRule;
+    function ParseExpression: TEBNFExpression;
+    function ParseTerm: TEBNFTerm;
+    function ParseFactor: TEBNFFactor;
+    function Peek(aTokenType: TEBNFTokenType): Boolean;
+    function Peek(aTokenTypes: TEBNFTokenTypes): Boolean;
+    procedure Error(const aMessage: string);
+  public
+    constructor Create(const aEBNFSource: string);
+    destructor Destroy; override;
+    function Parse: TEBNFGrammar;
+  end;
+
+implementation
+
+resourcestring
+  SErrParser = 'Parsing Error at pos %s (Token: %s, Value: "%s"): %s';
+  SExpectedFound = 'Expected %s, but found %s';
+  SErrExpectedIdentifierForRule = 'Expected identifier for rule name';
+  SerrExpectedStringLiteral = 'Expected string literal for special sequence value';
+  SerrUnexpectedFactorToken = 'Unexpected token for factor: %s';
+
+constructor TEBNFParser.Create(const aEBNFSource: string);
+begin
+  FSource := aEBNFSource;
+  FScanner := TEBNFScanner.Create(FSource);
+  FCurrentToken := FScanner.GetNextToken;
+end;
+
+destructor TEBNFParser.Destroy;
+begin
+  FScanner.Free;
+  inherited;
+end;
+
+procedure TEBNFParser.Error(const aMessage: string);
+begin
+  raise EEBNFParser.CreateFmt(SErrParser,
+    [FCurrentToken.Position.ToString, GetEnumName(TypeInfo(TEBNFTokenType), Ord(FCurrentToken.TokenType)), FCurrentToken.Value, aMessage]);
+end;
+
+procedure TEBNFParser.Consume(aTokenType: TEBNFTokenType);
+begin
+  if FCurrentToken.TokenType = aTokenType then
+    begin
+    FLastLine:=FCurrentToken.Position.Row;
+    FCurrentToken := FScanner.GetNextToken;
+    end
+  else
+    Error(Format(SExpectedFound,
+      [GetEnumName(TypeInfo(TEBNFTokenType), Ord(aTokenType)),
+       GetEnumName(TypeInfo(TEBNFTokenType), Ord(FCurrentToken.TokenType))]));
+end;
+
+function TEBNFParser.Peek(aTokenType: TEBNFTokenType): Boolean;
+begin
+  Result := FCurrentToken.TokenType = aTokenType;
+end;
+
+function TEBNFParser.Peek(aTokenTypes: TEBNFTokenTypes): Boolean;
+begin
+  Result := FCurrentToken.TokenType in aTokenTypes;
+end;
+
+function TEBNFParser.Parse: TEBNFGrammar;
+begin
+  Result := ParseGrammar;
+end;
+
+function TEBNFParser.ParseGrammar: TEBNFGrammar;
+var
+  Grammar: TEBNFGrammar;
+  Rule: TEBNFRule;
+begin
+  Rule:=nil;
+  Grammar := TEBNFGrammar.Create;
+  try
+    while FCurrentToken.TokenType <> ttEOF do
+    begin
+      Rule := ParseRule;
+      Grammar.AddRule(Rule);
+      Rule:=nil;
+    end;
+    Result := Grammar;
+  except
+    Rule.Free;
+    Grammar.Free;
+    raise;
+  end;
+end;
+
+function TEBNFParser.ParseRule: TEBNFRule;
+var
+  Identifier: string;
+  Expression: TEBNFExpression;
+begin
+  if FCurrentToken.TokenType <> ttIdentifier then
+    Error(SErrExpectedIdentifierForRule);
+  Identifier := FCurrentToken.Value;
+  Consume(ttIdentifier);
+
+  Consume(ttEquals);
+
+  Expression := ParseExpression;
+  try
+    Consume(ttSemicolon);
+    Result := TEBNFRule.Create(Identifier, Expression);
+    Expression := Nil;
+  except
+    Expression.Free;
+    Raise;
+  end;
+end;
+
+function TEBNFParser.ParseExpression: TEBNFExpression;
+var
+  Expression: TEBNFExpression;
+  Term: TEBNFTerm;
+  NewLine : boolean;
+begin
+  Expression := TEBNFExpression.Create;
+  try
+    Term := ParseTerm;
+    Expression.AddTerm(Term);
+    NewLine:=FLastLine<>FCurrentToken.Position.Row;
+    while Peek(ttPipe) do
+    begin
+      Consume(ttPipe);
+      Term := ParseTerm;
+      Term.newline:=NewLine;
+      Expression.AddTerm(Term);
+    end;
+    Result := Expression;
+  except
+    Expression.Free;
+    raise;
+  end;
+end;
+
+function TEBNFParser.ParseTerm: TEBNFTerm;
+var
+  Term: TEBNFTerm;
+  Factor: TEBNFFactor;
+begin
+  Term := TEBNFTerm.Create;
+  try
+    // A term is a sequence of factors.
+    // Loop until we hit a delimiter for expression or rule end.
+    while not Peek([ttPipe,ttSemicolon,ttCloseParen,ttCloseBracket,ttCloseBrace,ttEOF]) do
+    begin
+      Factor := ParseFactor;
+      Term.AddFactor(Factor);
+    end;
+    Result := Term;
+  except
+    Term.Free;
+    raise;
+  end;
+end;
+
+function TEBNFParser.ParseFactor: TEBNFFactor;
+var
+  InnerExpression: TEBNFExpression;
+  Value: string;
+begin
+  case FCurrentToken.TokenType of
+    ttIdentifier:
+    begin
+      Result := TEBNFFactor.Create(etFactorIdentifier, FCurrentToken.Value);
+      Consume(ttIdentifier);
+    end;
+    ttStringLiteral:
+    begin
+      Result := TEBNFFactor.Create(etFactorStringLiteral, FCurrentToken.Value);
+      Consume(ttStringLiteral);
+    end;
+    ttOpenBracket: // Optional group [ expression ]
+    begin
+      Consume(ttOpenBracket);
+      InnerExpression := ParseExpression;
+      try
+        Consume(ttCloseBracket);
+        Result := TEBNFFactor.Create(etFactorOptional, InnerExpression);
+        InnerExpression:=nil;
+      except
+        InnerExpression.Free;
+        Raise;
+      end;
+    end;
+    ttOpenBrace: // Repetition group { expression }
+    begin
+      Consume(ttOpenBrace);
+      InnerExpression := ParseExpression;
+      try
+        Consume(ttCloseBrace);
+        Result := TEBNFFactor.Create(etFactorRepetition, InnerExpression);
+        InnerExpression:=nil;
+      except
+        InnerExpression.Free;
+        Raise;
+      end;
+    end;
+    ttOpenParen: // Group ( expression )
+    begin
+      Consume(ttOpenParen);
+      InnerExpression := ParseExpression;
+      try
+        Consume(ttCloseParen);
+        Result := TEBNFFactor.Create(etFactorGroup, InnerExpression);
+        InnerExpression:=nil;
+      except
+        InnerExpression.Free;
+        Raise;
+      end;
+    end;
+    ttQuestion: // Special sequence ? value ?
+    begin
+      Consume(ttQuestion);
+      if FCurrentToken.TokenType <> ttStringLiteral then
+        Error(SerrExpectedStringLiteral);
+      Value := FCurrentToken.Value;
+      Consume(ttStringLiteral);
+      Consume(ttQuestion);
+      Result := TEBNFFactor.Create(etFactorSpecialSequence, Value);
+    end;
+    else
+      Error(Format(SerrUnexpectedFactorToken, [GetEnumName(TypeInfo(TEBNFTokenType), Ord(FCurrentToken.TokenType))]));
+  end;
+end;
+
+end.

+ 270 - 0
packages/fcl-ebnf/src/ebnf.scanner.pp

@@ -0,0 +1,270 @@
+{
+    This file is part of the Free Component Library (FCL)
+    Copyright (c) 2025 by Michael Van Canneyt ([email protected])
+
+    EBNF scanner
+
+    See the file COPYING.FPC, included in this distribution,
+    for details about the copyright.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+ **********************************************************************}
+
+unit ebnf.scanner;
+
+{$mode objfpc}
+{$h+}
+{$modeswitch advancedrecords}
+
+interface
+
+{$IFDEF FPC_DOTTEDUNITS}
+uses System.SysUtils;
+{$ELSE}
+uses sysutils;
+{$ENDIF}
+// --- Scanner/Tokenizer Definitions ---
+
+type
+  EEBNFScanner = class(Exception);
+
+  TEBNFTokenType = (
+    ttUnknown,
+    ttIdentifier,
+    ttStringLiteral,
+    ttEquals,       // =
+    ttPipe,         // |
+    ttOpenParen,    // (
+    ttCloseParen,   // )
+    ttOpenBracket,  // [
+    ttCloseBracket, // ]
+    ttOpenBrace,    // {
+    ttCloseBrace,   // }
+    ttSemicolon,    // ;
+    ttQuestion,     // ? (for special sequences)
+    ttEOF           // End of File
+  );
+  TEBNFTokenTypes = set of TEBNFTokenType;
+
+  { TTokenPosition }
+
+  TTokenPosition = record
+    Position: Integer;
+    Col : Integer;
+    Row : integer;
+    function ToString : string;
+  end;
+
+  TToken = record
+    TokenType: TEBNFTokenType;
+    Value: string;
+    Position : TTokenPosition; // Start position in the source string
+  end;
+
+  { TEBNFScanner }
+
+  TEBNFScanner = class
+  private
+    FSource: string;
+    FPos : TTokenPosition;
+    FCurrentChar: Char;
+    FNewLine : boolean;
+    procedure SkipComment;
+  protected
+    // Returns previous char
+    function Advance: Char;
+    function CurrentTokenPos : TTokenPosition;
+    procedure SkipWhitespace;
+    function IsIdentifierStart(aChar: Char): Boolean;
+    function IsIdentifierChar(aChar: Char): Boolean;
+    function IsDigit(aChar: Char): Boolean;
+    function ReadIdentifier: string;
+    function ReadStringLiteral(aQuoteChar: Char): string;
+  public
+    constructor Create(const aSource: string);
+    function GetNextToken: TToken;
+  end;
+
+implementation
+
+Resourcestring
+  SErrUnterminatedComment = 'Unterminated comment block';
+  SErrUnknownToken = 'Unknown token: "%s" at position %s';
+  SErrUnterminatedString = 'Unterminated string literal';
+
+{ TTokenPosition }
+
+function TTokenPosition.ToString: string;
+begin
+  Result:=format('pos %d (row: %d, col: %d)',[Position,Row,Col]);
+end;
+
+constructor TEBNFScanner.Create(const aSource: string);
+begin
+  FSource := aSource;
+  FPos.Position:=1;
+  FNewLine:=True;
+  FPos.Col:=0; // Delphi strings are 1-indexed
+  FPos.Row:=1;
+  FCurrentChar := #0; // Initialize
+  if Length(FSource) > 0 then
+    FCurrentChar := FSource[FPos.Position];
+end;
+
+function TEBNFScanner.Advance: Char;
+begin
+  Result := FCurrentChar;
+  if FCurrentChar=#10 then
+    begin
+    inc(FPos.Row);
+    FPos.Col:=0;
+    FNewLine:=True;
+    end;
+  inc(FPos.Col);
+  Inc(FPos.Position);
+  if FPos.Position <= Length(FSource) then
+    FCurrentChar := FSource[FPos.Position]
+  else
+    FCurrentChar := #0; // EOF
+  FNewLine:=FNewLine and (FCurrentChar=' ');
+end;
+
+function TEBNFScanner.CurrentTokenPos: TTokenPosition;
+begin
+  Result:=FPos;
+end;
+
+procedure TEBNFScanner.SkipWhitespace;
+begin
+  while (FCurrentChar <> #0) and (FCurrentChar <= ' ') do // Includes space, tab, newline, etc.
+    Advance;
+end;
+
+function TEBNFScanner.IsIdentifierStart(aChar: Char): Boolean;
+begin
+  Result := (aChar >= 'a') and (aChar <= 'z') or
+            (aChar >= 'A') and (aChar <= 'Z');
+end;
+
+function TEBNFScanner.IsIdentifierChar(aChar: Char): Boolean;
+begin
+  Result := IsIdentifierStart(aChar) or IsDigit(aChar) or (aChar = '_');
+end;
+
+function TEBNFScanner.IsDigit(aChar: Char): Boolean;
+begin
+  Result := (aChar >= '0') and (aChar <= '9');
+end;
+
+function TEBNFScanner.ReadIdentifier: string;
+var
+  StartPos: Integer;
+begin
+  StartPos := FPos.Position;
+  while IsIdentifierChar(FCurrentChar) do
+    Advance;
+  Result := Copy(FSource, StartPos, FPos.Position - StartPos);
+end;
+
+function TEBNFScanner.ReadStringLiteral(aQuoteChar: Char): string;
+var
+  LiteralValue: string;
+begin
+  Advance; // Consume the opening quote
+  LiteralValue := '';
+  while (FCurrentChar <> #0) and (FCurrentChar <> aQuoteChar) do
+  begin
+    LiteralValue := LiteralValue + FCurrentChar;
+    Advance;
+  end;
+  if FCurrentChar <> aQuoteChar then
+    raise EEBNFScanner.Create(SErrUnterminatedString);
+  Advance; // Consume the closing quote
+  Result := LiteralValue;
+end;
+
+function TEBNFScanner.GetNextToken: TToken;
+var
+  HaveComment : Boolean;
+begin
+  repeat
+    SkipWhitespace;
+    HaveComment:=(FCurrentChar = '(') and (FPos.Position + 1 <= Length(FSource)) and (FSource[FPos.Position + 1] = '*');
+    if HaveComment then
+      SkipComment
+  until Not HaveComment;
+
+  Result.Position := FPos;
+  Result.Value := '';
+
+  if FCurrentChar = #0 then
+  begin
+    Result.TokenType := ttEOF;
+    Exit;
+  end;
+
+  if IsIdentifierStart(FCurrentChar) then
+  begin
+    Result.TokenType := ttIdentifier;
+    Result.Value := ReadIdentifier;
+    Exit;
+  end;
+
+  if (FCurrentChar = '''') or (FCurrentChar = '"') then
+  begin
+    Result.TokenType := ttStringLiteral;
+    Result.Value := ReadStringLiteral(FCurrentChar);
+    Exit;
+  end;
+
+  case FCurrentChar of
+    '=': Result.TokenType := ttEquals;
+    '|': Result.TokenType := ttPipe;
+    '(': Result.TokenType := ttOpenParen;
+    ')': Result.TokenType := ttCloseParen;
+    '[': Result.TokenType := ttOpenBracket;
+    ']': Result.TokenType := ttCloseBracket;
+    '{': Result.TokenType := ttOpenBrace;
+    '}': Result.TokenType := ttCloseBrace;
+    ';': Result.TokenType := ttSemicolon;
+    '?': Result.TokenType := ttQuestion;
+    else
+      Result.TokenType := ttUnknown;
+      Result.Value := FCurrentChar;
+      raise EEBNFScanner.CreateFmt(SErrUnknownToken, [FCurrentChar, FPos.ToString]);
+  end;
+  Advance; // Consume the character
+end;
+
+procedure TEBNFScanner.SkipComment;
+var
+  FoundEnd: Boolean;
+begin
+  Advance; // Consume '('
+  Advance; // Consume '*'
+  FoundEnd := False;
+  while (FCurrentChar <> #0) and not FoundEnd do
+  begin
+    if (FCurrentChar = '*') then
+    begin
+      Advance; // Consume '*'
+      if (FCurrentChar = ')') then
+      begin
+        Advance; // Consume ')'
+        FoundEnd := True;
+      end;
+    end
+    else
+    begin
+      Advance; // Consume any other character
+    end;
+  end;
+  if not FoundEnd then
+    raise EEBNFScanner.Create(SErrUnterminatedComment);
+end;
+
+
+end.

+ 382 - 0
packages/fcl-ebnf/src/ebnf.tree.pp

@@ -0,0 +1,382 @@
+{
+    This file is part of the Free Component Library (FCL)
+    Copyright (c) 2025 by Michael Van Canneyt ([email protected])
+
+    EBNF AST elements
+
+    See the file COPYING.FPC, included in this distribution,
+    for details about the copyright.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+ **********************************************************************}
+
+unit ebnf.tree;
+
+{$mode objfpc}
+{$h+}
+{$modeswitch advancedrecords}
+
+interface
+
+uses
+  {$IFDEF FPC_DOTTEDUNITS}
+  System.SysUtils, System.Classes, System.Contnrs, System.TypInfo;
+  {$ELSE}
+  SysUtils, Classes, Contnrs, typinfo;
+  {$ENDIF}
+
+type
+  TEBNFElementType = (
+    etGrammar,
+    etRule,
+    etExpression,
+    etTerm,
+    etFactorIdentifier,
+    etFactorStringLiteral,
+    etFactorOptional,
+    etFactorRepetition,
+    etFactorGroup,
+    etFactorSpecialSequence
+  );
+
+  { TEBNFElement }
+
+  TEBNFElement = class
+  private
+    FNodeType: TEBNFElementType;
+  protected
+    function GetChildCount : Integer; virtual;
+    function GetChild(aIndex : Integer): TEBNFElement; virtual;
+  public
+    constructor Create(aNodeType: TEBNFElementType);
+    property Child[aIdex : Integer] : TEBNFElement read GetChild;
+    property ChildCount : Integer Read GetChildCount;
+    property NodeType: TEBNFElementType read FNodeType;
+    function ToString: string; override;
+  end;
+
+  { TEBNFElementWithChildren }
+
+  TEBNFElementWithChildren = class (TEBNFElement)
+  Private
+    FChildren: TFPObjectList;
+  protected
+    function GetChildCount : Integer; override;
+    function GetChild(aIndex : Integer): TEBNFElement; override;
+  public
+    constructor Create(aNodeType: TEBNFElementType);
+    destructor Destroy; override;
+  end;
+
+  { TEBNFFactor }
+
+  TEBNFFactor = class(TEBNFElement)
+  private
+    FValue: string;
+    FInnerNode: TEBNFElement;
+  public
+    constructor Create(aNodeType: TEBNFElementType); overload;
+    constructor Create(aNodeType: TEBNFElementType; aValue: string); overload;
+    constructor Create(aNodeType: TEBNFElementType; aInnerNode: TEBNFElement); overload;
+    destructor Destroy; override;
+    property Value: string read FValue;
+    property InnerNode: TEBNFElement read FInnerNode;
+    function ToString: string; override;
+  end;
+
+  { TEBNFTerm }
+
+  TEBNFTerm = class(TEBNFElementWithChildren)
+  private
+    FNewLine: boolean;
+    function GetFactor(aIndex : integer) : TEBNFFactor;
+  public
+    constructor Create;
+    destructor Destroy; override;
+    procedure AddFactor(aFactor: TEBNFFactor);
+    property Factors [aIndex : Integer] : TEBNFFactor read GetFactor;
+    function ToString: string; override;
+    property newline : boolean Read FNewLine Write FNewLine;
+  end;
+
+  { TEBNFExpression }
+
+  TEBNFExpression = class(TEBNFElementWithChildren)
+  private
+    function GetTerm(aIndex : integer): TEBNFTerm;
+  public
+    constructor Create;
+    procedure AddTerm(aTerm: TEBNFTerm);
+    property Terms[aIndex : integer] : TEBNFTerm read GetTerm; default;
+    function ToString: string; override;
+  end;
+
+  { TEBNFRule }
+
+  TEBNFRule = class(TEBNFElement)
+  private
+    FIdentifier: string;
+    FExpression: TEBNFElement; // Will be TEBNFExpression
+  public
+    constructor Create(aIdentifier: string; aExpression: TEBNFElement);
+    destructor Destroy; override;
+    property Identifier: string read FIdentifier;
+    property Expression: TEBNFElement read FExpression;
+    function ToString: string; override;
+  end;
+
+  { TEBNFGrammar }
+
+  TEBNFGrammar = class(TEBNFElementWithChildren)
+  private
+    FRules: TFPObjectHashTable;
+    function GetRule(const aName : string) : TEBNFRule; 
+  public
+    constructor Create;
+    destructor Destroy; override;
+    procedure AddRule(aRule: TEBNFRule);
+    property Rules[aName : string]: TEBNFRule read GetRule;
+    function ToString: string; override;
+  end;
+
+
+implementation
+
+
+{  TEBNFElement }
+
+function TEBNFElement.GetChildCount: Integer;
+begin
+  Result:=0;
+end;
+
+function TEBNFElement.GetChild(aIndex: Integer): TEBNFElement;
+begin
+  if aIndex<0 then ;
+  Result:=Nil;
+end;
+
+constructor TEBNFElement.Create(aNodeType: TEBNFElementType);
+begin
+  FNodeType := aNodeType;
+end;
+
+function TEBNFElement.ToString: string;
+begin
+  Result := Format('Node Type: %s', [GetEnumName(TypeInfo(TEBNFElementType), Ord(FNodeType))]);
+end;
+
+{ TEBNFElementWithChildren }
+
+function TEBNFElementWithChildren.GetChildCount: Integer;
+begin
+  Result:=FChildren.Count;
+end;
+
+function TEBNFElementWithChildren.GetChild(aIndex: Integer): TEBNFElement;
+begin
+  Result:=TEBNFElement(FChildren[aIndex]);
+end;
+
+constructor TEBNFElementWithChildren.Create(aNodeType: TEBNFElementType);
+begin
+  Inherited create(aNodeType);
+  FChildren:=TFPObjectList.Create(True);
+end;
+
+destructor TEBNFElementWithChildren.Destroy;
+begin
+  FreeAndNil(FChildren);
+  inherited Destroy;
+end;
+
+// --- TEBNFRule Implementation ---
+
+constructor TEBNFRule.Create(aIdentifier: string; aExpression: TEBNFElement);
+begin
+  inherited Create(etRule);
+  FIdentifier := aIdentifier;
+  FExpression := aExpression;
+end;
+
+destructor TEBNFRule.Destroy;
+begin
+  FExpression.Free;
+  inherited;
+end;
+
+function TEBNFRule.ToString: string;
+begin
+  Result := Format('%s = %s;', [FIdentifier, FExpression.ToString]);
+end;
+
+{ TEBNFExpression }
+
+function TEBNFExpression.GetTerm(aIndex : integer): TEBNFTerm;
+begin
+  Result:=Child[aIndex] as TEBNFTerm;
+end;
+
+constructor TEBNFExpression.Create;
+begin
+  inherited Create(etExpression);
+end;
+
+procedure TEBNFExpression.AddTerm(aTerm: TEBNFTerm);
+begin
+  FChildren.Add(aTerm);
+end;
+
+function TEBNFExpression.ToString: string;
+var
+  lRes : String;
+  lTerm : TEBNFElement ;
+  I : Integer;
+begin
+  lRes:='';
+  for I:=0 to ChildCount-1 do
+    begin
+    LTerm:=Terms[i];
+    if lRes<>'' then
+      begin
+      if (LTerm is TEBNFTerm) and TEBNFTerm(lTerm).NewLine then
+        lRes:=lRes+sLineBreak;
+      lRes:=lRes+' | ';
+      end;
+    lRes:=lRes+lTerm.ToString;
+    end;
+   Result:=lRes;
+end;
+
+{ TEBNFTerm }
+
+function TEBNFTerm.GetFactor(aIndex: integer): TEBNFFactor;
+begin
+  Result:=Child[aIndex] as TEBNFFactor
+end;
+
+constructor TEBNFTerm.Create;
+begin
+  inherited Create(etTerm);
+end;
+
+destructor TEBNFTerm.Destroy;
+
+begin
+  inherited;
+end;
+
+procedure TEBNFTerm.AddFactor(aFactor: TEBNFFactor);
+begin
+  FChildren.Add(aFactor);
+end;
+
+function TEBNFTerm.ToString: string;
+var
+  lRes : String;
+  I : Integer;
+begin
+  lRes:='';
+  for I:=0 to ChildCount-1 do
+    begin
+    if lRes<>'' then
+      lRes:=lRes+' ';
+    lRes:=lRes+Child[i].ToString;
+    end;
+  Result:=lRes;
+end;
+
+{ TEBNFFactor }
+
+constructor TEBNFFactor.Create(aNodeType: TEBNFElementType);
+begin
+  inherited Create(aNodeType);
+  FValue := '';
+  FInnerNode := nil;
+end;
+
+constructor TEBNFFactor.Create(aNodeType: TEBNFElementType; aValue: string);
+begin
+  inherited Create(aNodeType);
+  FValue := aValue;
+  FInnerNode := nil;
+end;
+
+constructor TEBNFFactor.Create(aNodeType: TEBNFElementType; aInnerNode: TEBNFElement);
+begin
+  inherited Create(aNodeType);
+  FValue := '';
+  FInnerNode := aInnerNode;
+end;
+
+destructor TEBNFFactor.Destroy;
+begin
+  FInnerNode.Free;
+  inherited;
+end;
+
+function TEBNFFactor.ToString: string;
+begin
+  case FNodeType of
+    etFactorIdentifier: Result := FValue;
+    etFactorStringLiteral: Result := AnsiQuotedStr(FValue, '"');
+    etFactorOptional: Result := Format('[%s]', [FInnerNode.ToString]);
+    etFactorRepetition: Result := Format('{%s}', [FInnerNode.ToString]);
+    etFactorGroup: Result := Format('(%s)', [FInnerNode.ToString]);
+    etFactorSpecialSequence: Result := Format('?%s?', [FValue]);
+  else
+    Result := inherited ToString;
+  end;
+end;
+
+{ TEBNFGrammar }
+
+function TEBNFGrammar.GetRule(const aName: string): TEBNFRule;
+begin
+  Result:=TEBNFRule(FRules.Items[aName]);
+end;
+
+constructor TEBNFGrammar.Create;
+begin
+  inherited Create(etGrammar);
+  FRules := TFPObjectHashTable.Create(False);
+end;
+
+destructor TEBNFGrammar.Destroy;
+
+begin
+  FreeAndNil(FRules);
+  inherited;
+end;
+
+procedure TEBNFGrammar.AddRule(aRule: TEBNFRule);
+begin
+  if FRules.Find(aRule.Identifier)<>Nil then
+    raise Exception.CreateFmt('Duplicate rule identifier: %s', [aRule.Identifier]);
+  FChildren.Add(aRule);
+  FRules.Add(aRule.Identifier, aRule);
+end;
+
+function TEBNFGrammar.ToString: string;
+var
+  I : integer;
+  lList : TStrings;
+begin
+  Result := '';
+  lList:=TStringList.Create;
+  try
+    for I:=0 to ChildCount-1 do
+      begin
+      lList.Add(Child[i].ToString);
+      lList.Add('');
+      end;
+    Result:=lList.Text;
+  finally
+    lList.Free;
+  end;
+end;
+
+end.

+ 80 - 0
packages/fcl-ebnf/tests/testebnf.lpi

@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<CONFIG>
+  <ProjectOptions>
+    <Version Value="12"/>
+    <General>
+      <Flags>
+        <MainUnitHasCreateFormStatements Value="False"/>
+        <MainUnitHasTitleStatement Value="False"/>
+        <MainUnitHasScaledStatement Value="False"/>
+      </Flags>
+      <SessionStorage Value="InProjectDir"/>
+      <Title Value="testebnf"/>
+      <UseAppBundle Value="False"/>
+      <ResourceType Value="res"/>
+    </General>
+    <BuildModes>
+      <Item Name="Default" Default="True"/>
+    </BuildModes>
+    <PublishOptions>
+      <Version Value="2"/>
+      <UseFileFilters Value="True"/>
+    </PublishOptions>
+    <RunParams>
+      <FormatVersion Value="2"/>
+    </RunParams>
+    <RequiredPackages>
+      <Item>
+        <PackageName Value="FCL"/>
+      </Item>
+    </RequiredPackages>
+    <Units>
+      <Unit>
+        <Filename Value="testebnf.lpr"/>
+        <IsPartOfProject Value="True"/>
+      </Unit>
+      <Unit>
+        <Filename Value="utcscanner.pas"/>
+        <IsPartOfProject Value="True"/>
+      </Unit>
+      <Unit>
+        <Filename Value="utcparser.pas"/>
+        <IsPartOfProject Value="True"/>
+      </Unit>
+      <Unit>
+        <Filename Value="utctree.pas"/>
+        <IsPartOfProject Value="True"/>
+      </Unit>
+    </Units>
+  </ProjectOptions>
+  <CompilerOptions>
+    <Version Value="11"/>
+    <Target>
+      <Filename Value="testebnf"/>
+    </Target>
+    <SearchPaths>
+      <IncludeFiles Value="$(ProjOutDir)"/>
+      <OtherUnitFiles Value="../src"/>
+      <UnitOutputDirectory Value="lib/$(TargetCPU)-$(TargetOS)"/>
+    </SearchPaths>
+    <Linking>
+      <Debugging>
+        <DebugInfoType Value="dsDwarf3"/>
+        <UseHeaptrc Value="True"/>
+      </Debugging>
+    </Linking>
+  </CompilerOptions>
+  <Debugging>
+    <Exceptions>
+      <Item>
+        <Name Value="EAbort"/>
+      </Item>
+      <Item>
+        <Name Value="ECodetoolError"/>
+      </Item>
+      <Item>
+        <Name Value="EFOpenError"/>
+      </Item>
+    </Exceptions>
+  </Debugging>
+</CONFIG>

+ 28 - 0
packages/fcl-ebnf/tests/testebnf.lpr

@@ -0,0 +1,28 @@
+program testebnf;
+
+{$mode objfpc}{$H+}
+
+uses
+  Classes, consoletestrunner, utcscanner, utcparser, utctree;
+
+type
+
+  { TMyTestRunner }
+
+  TMyTestRunner = class(TTestRunner)
+  protected
+  // override the protected methods of TTestRunner to customize its behavior
+  end;
+
+var
+  Application: TMyTestRunner;
+
+begin
+  DefaultRunAllTests:=True;
+  DefaultFormat:=fPlain;
+  Application := TMyTestRunner.Create(nil);
+  Application.Initialize;
+  Application.Title := 'FPCUnit Console test runner';
+  Application.Run;
+  Application.Free;
+end.

+ 416 - 0
packages/fcl-ebnf/tests/utcparser.pas

@@ -0,0 +1,416 @@
+{
+    This file is part of the Free Component Library (FCL)
+    Copyright (c) 2025 by Michael Van Canneyt ([email protected])
+
+    Test EBNF Parser
+
+    See the file COPYING.FPC, included in this distribution,
+    for details about the copyright.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+ **********************************************************************}
+
+unit utcparser;
+
+interface
+
+uses
+  SysUtils, classes, fpcunit, testregistry,
+  ebnf.tree,
+  ebnf.parser;
+
+type
+
+  { TTestEBNFParser }
+
+  TTestEBNFParser = class(TTestCase)
+  private
+    FGrammar: TEBNFGrammar;
+    FParser: TEBNFParser;
+  protected
+
+    procedure TearDown; override;
+
+    procedure AssertEquals(const Msg : String; aExpected, aActual : TEBNFElementType); overload;
+    procedure CheckEquals(aExpected, aActual : TEBNFElementType; const Msg : String = ''); overload;
+    Property Parser : TEBNFParser Read FParser Write FParser;
+    Property Grammar : TEBNFGrammar Read FGrammar Write FGrammar;
+  published
+    procedure TestOneRuleOneTermOneFactor;
+    procedure TestOneRuleOneTermTwoFactors;
+    procedure TestOneRuleTwoTermsOneFactorEach;
+    procedure TestOneRuleTwoTermsTwoFactorsEach;
+    procedure TestTwoRulesOneTermOneFactorEach;
+    procedure TestRuleWithOptionalGroup;
+    procedure TestRuleWithRepetitionGroup;
+    procedure TestRuleWithParenthesizedGroup;
+    procedure TestRuleWithSpecialSequence;
+    procedure TestDuplicateRuleError;
+    procedure TestMissingEqualsError;
+    procedure TestMissingSemicolonError;
+    procedure TestUnexpectedTokenInFactorError;
+  end;
+
+implementation
+
+uses typinfo;
+
+procedure TTestEBNFParser.AssertEquals(const Msg: String; aExpected, aActual: TEBNFElementType);
+begin
+  AssertEquals(Msg,GetEnumName(typeInfo(TEBNFElementType),ord(aExpected)),
+                  GetEnumName(typeInfo(TEBNFElementType),ord(aActual)));
+end;
+
+procedure TTestEBNFParser.CheckEquals(aExpected, aActual: TEBNFElementType; const Msg: String);
+begin
+  AssertEquals(Msg,aExpected,aActual);
+end;
+
+procedure TTestEBNFParser.TearDown;
+begin
+  FreeAndNil(FParser);
+  FreeAndNil(FGrammar);
+  inherited TearDown;
+end;
+
+procedure TTestEBNFParser.TestOneRuleOneTermOneFactor;
+var
+  EBNFSource: string;
+  Rule: TEBNFRule;
+  Expression: TEBNFExpression;
+  Term: TEBNFTerm;
+  Factor: TEBNFFactor;
+begin
+  EBNFSource := 'rule1 = "literal" ;';
+  Parser := TEBNFParser.Create(EBNFSource);
+  Grammar := Parser.Parse;
+  CheckEquals(1, Grammar.ChildCount, 'Expected 1 rule in grammar');
+  Rule:=Grammar.Rules['rule1'];
+  CheckNotNull(Rule, 'Rule object should not be nil');
+
+  CheckEquals(etExpression, Rule.Expression.NodeType, 'Rule expression should be of type anExpression');
+  Expression := TEBNFExpression(Rule.Expression);
+  CheckEquals(1, Expression.ChildCount, 'Expected 1 term in expression');
+
+  CheckEquals(etTerm, Expression.Terms[0].NodeType, 'Expression term should be of type anTerm');
+  Term := TEBNFTerm(Expression.Terms[0]);
+  CheckEquals(1, Term.ChildCount, 'Expected 1 factor in term');
+
+  CheckEquals(etFactorStringLiteral, Term.Factors[0].NodeType, 'Term factor should be of type anFactorStringLiteral');
+  Factor := TEBNFFactor(Term.Factors[0]);
+  CheckEquals('literal', Factor.Value, 'Factor value should be "literal"');
+end;
+
+procedure TTestEBNFParser.TestOneRuleOneTermTwoFactors;
+var
+  EBNFSource: string;
+  Rule: TEBNFRule;
+  Expression: TEBNFExpression;
+  Term: TEBNFTerm;
+  Factor1, Factor2: TEBNFFactor;
+begin
+  EBNFSource := 'rule2 = identifier1 "literal2" ;';
+  Parser := TEBNFParser.Create(EBNFSource);
+  Grammar := Parser.Parse;
+  CheckEquals(1, Grammar.ChildCount, 'Expected 1 rule in grammar');
+  Rule:=Grammar.Rules['rule2'];
+  CheckNotNull(Rule, 'Expected rule "rule2" to exist');
+
+  Expression := TEBNFExpression(Rule.Expression);
+  CheckEquals(1, Expression.ChildCount, 'Expected 1 term in expression');
+
+  Term := TEBNFTerm(Expression.Terms[0]);
+  CheckEquals(2, Term.ChildCount, 'Expected 2 factors in term');
+
+  Factor1 := TEBNFFactor(Term.Factors[0]);
+  CheckEquals(etFactorIdentifier, Factor1.NodeType, 'First factor should be identifier');
+  CheckEquals('identifier1', Factor1.Value, 'First factor value should be "identifier1"');
+
+  Factor2 := TEBNFFactor(Term.Factors[1]);
+  CheckEquals(etFactorStringLiteral, Factor2.NodeType, 'Second factor should be string literal');
+  CheckEquals('literal2', Factor2.Value, 'Second factor value should be "literal2"');
+end;
+
+procedure TTestEBNFParser.TestOneRuleTwoTermsOneFactorEach;
+var
+  EBNFSource: string;
+  Rule: TEBNFRule;
+  Expression: TEBNFExpression;
+  Term1, Term2: TEBNFTerm;
+  Factor1, Factor2: TEBNFFactor;
+begin
+  EBNFSource := 'rule3 = factorA | factorB ;';
+  Parser := TEBNFParser.Create(EBNFSource);
+  Grammar := Parser.Parse;
+  CheckEquals(1, Grammar.ChildCount, 'Expected 1 rule in grammar');
+  Rule:=Grammar.Rules['rule3'];
+  ChecknotNull(Rule, 'Expected rule "rule3" to exist');
+
+  Expression := TEBNFExpression(Rule.Expression);
+  CheckEquals(2, Expression.ChildCount, 'Expected 2 terms in expression');
+
+  // First term
+  Term1 := TEBNFTerm(Expression.Terms[0]);
+  CheckEquals(1, Term1.ChildCount, 'Expected 1 factor in first term');
+  Factor1 := TEBNFFactor(Term1.Factors[0]);
+  CheckEquals(etFactorIdentifier, Factor1.NodeType, 'First term factor should be identifier');
+  CheckEquals('factorA', Factor1.Value, 'First term factor value should be "factorA"');
+
+  // Second term
+  Term2 := TEBNFTerm(Expression.Terms[1]);
+  CheckEquals(1, Term2.ChildCount, 'Expected 1 factor in second term');
+  Factor2 := TEBNFFactor(Term2.Factors[0]);
+  CheckEquals(etFactorIdentifier, Factor2.NodeType, 'Second term factor should be identifier');
+  CheckEquals('factorB', Factor2.Value, 'Second term factor value should be "factorB"');
+end;
+
+procedure TTestEBNFParser.TestOneRuleTwoTermsTwoFactorsEach;
+var
+  EBNFSource: string;
+  Rule: TEBNFRule;
+  Expression: TEBNFExpression;
+  Term1, Term2: TEBNFTerm;
+  Factor: TEBNFFactor;
+begin
+  EBNFSource := 'rule4 = (id1 "lit1") | (id2 "lit2") ;';
+  Parser := TEBNFParser.Create(EBNFSource);
+  Grammar := Parser.Parse;
+  CheckEquals(1, Grammar.ChildCount, 'Expected 1 rule in grammar');
+  Rule:=Grammar.Rules['rule4'];
+  CheckNotNull(Rule, 'Expected rule "rule4" to exist');
+
+  Expression := TEBNFExpression(Rule.Expression);
+  CheckEquals(2, Expression.ChildCount, 'Expected 2 terms in expression');
+
+  // First term (group)
+  Term1 := TEBNFTerm(Expression.Terms[0]);
+  CheckEquals(1, Term1.ChildCount, 'Expected 1 factor (group) in first term');
+  Factor := TEBNFFactor(Term1.Factors[0]);
+  CheckEquals(etFactorGroup, Factor.NodeType, 'Factor should be a group');
+  CheckNotNull(Factor.InnerNode, 'Inner node of group should not be nil');
+
+  // Check inner expression of first group
+  CheckEquals(etExpression, Factor.InnerNode.NodeType, 'Inner node should be an expression');
+  CheckEquals(1, TEBNFExpression(Factor.InnerNode).ChildCount, 'Inner expression should have 1 term');
+  CheckEquals(2, TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).ChildCount, 'Inner term should have 2 factors');
+  CheckEquals('id1', TEBNFFactor(TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).Factors[0]).Value, 'Inner factor 1 value');
+  CheckEquals('lit1', TEBNFFactor(TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).Factors[1]).Value, 'Inner factor 2 value');
+
+  // Second term (group)
+  Term2 := TEBNFTerm(Expression.Terms[1]);
+  CheckEquals(1, Term2.ChildCount, 'Expected 1 factor (group) in second term');
+  Factor := TEBNFFactor(Term2.Factors[0]);
+  CheckEquals(etFactorGroup, Factor.NodeType, 'Factor should be a group');
+  CheckNotNull(Factor.InnerNode, 'Inner node of group should not be nil');
+
+  // Check inner expression of second group
+  CheckEquals(etExpression, Factor.InnerNode.NodeType, 'Inner node should be an expression');
+  CheckEquals(1, TEBNFExpression(Factor.InnerNode).ChildCount, 'Inner expression should have 1 term');
+  CheckEquals(2, TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).ChildCount, 'Inner term should have 2 factors');
+  CheckEquals('id2', TEBNFFactor(TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).Factors[0]).Value, 'Inner factor 1 value');
+  CheckEquals('lit2', TEBNFFactor(TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).Factors[1]).Value, 'Inner factor 2 value');
+
+end;
+
+procedure TTestEBNFParser.TestTwoRulesOneTermOneFactorEach;
+var
+  EBNFSource: string;
+  Rule1, Rule2: TEBNFRule;
+begin
+  EBNFSource :=
+    'ruleA = "first" ;' + sLineBreak +
+    'ruleB = identifierB ;';
+  Parser := TEBNFParser.Create(EBNFSource);
+  Grammar := Parser.Parse;
+  CheckEquals(2, Grammar.ChildCount, 'Expected 2 rules in grammar');
+
+  // Check ruleA
+  Rule1:=Grammar.Rules['ruleA'];
+  CheckNotNull(Rule1, 'Expected rule "ruleA" to exist');
+  CheckEquals(etExpression, Rule1.Expression.NodeType, 'RuleA expression type');
+  CheckEquals(1, TEBNFExpression(Rule1.Expression).ChildCount, 'RuleA expression terms count');
+  CheckEquals(1, TEBNFTerm(TEBNFExpression(Rule1.Expression).Terms[0]).ChildCount, 'RuleA term factors count');
+  CheckEquals(etFactorStringLiteral, TEBNFFactor(TEBNFTerm(TEBNFExpression(Rule1.Expression).Terms[0]).Factors[0]).NodeType, 'RuleA factor type');
+  CheckEquals('first', TEBNFFactor(TEBNFTerm(TEBNFExpression(Rule1.Expression).Terms[0]).Factors[0]).Value, 'RuleA factor value');
+
+  // Check ruleB
+  Rule2:=Grammar.Rules['ruleB'];
+  CheckNotNull(Rule2, 'Expected rule "ruleB" to exist');
+  CheckEquals(etExpression, Rule2.Expression.NodeType, 'RuleB expression type');
+  CheckEquals(1, TEBNFExpression(Rule2.Expression).ChildCount, 'RuleB expression terms count');
+  CheckEquals(1, TEBNFTerm(TEBNFExpression(Rule2.Expression).Terms[0]).ChildCount, 'RuleB term factors count');
+  CheckEquals(etFactorIdentifier, TEBNFFactor(TEBNFTerm(TEBNFExpression(Rule2.Expression).Terms[0]).Factors[0]).NodeType, 'RuleB factor type');
+  CheckEquals('identifierB', TEBNFFactor(TEBNFTerm(TEBNFExpression(Rule2.Expression).Terms[0]).Factors[0]).Value, 'RuleB factor value');
+end;
+
+procedure TTestEBNFParser.TestRuleWithOptionalGroup;
+var
+  EBNFSource: string;
+  Rule: TEBNFRule;
+  Expression: TEBNFExpression;
+  Term: TEBNFTerm;
+  Factor: TEBNFFactor;
+begin
+  EBNFSource := 'optional_rule = [ "optional_part" ] ;';
+  Parser := TEBNFParser.Create(EBNFSource);
+  Grammar := Parser.Parse;
+  Rule:=Grammar.Rules['optional_rule'];
+  CheckNotNull(Rule, 'Expected rule "optional_rule"');
+  Expression := TEBNFExpression(Rule.Expression);
+  Term := TEBNFTerm(Expression.Terms[0]);
+  Factor := TEBNFFactor(Term.Factors[0]);
+
+  CheckEquals(etFactorOptional, Factor.NodeType, 'Factor should be an optional group');
+  CheckNotNull(Factor.InnerNode, 'Optional group should have an inner node');
+  CheckEquals(etExpression, Factor.InnerNode.NodeType, 'Inner node should be an expression');
+  CheckEquals(1, TEBNFExpression(Factor.InnerNode).ChildCount, 'Inner expression should have 1 term');
+  CheckEquals(1, TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).ChildCount, 'Inner term should have 1 factor');
+  CheckEquals(etFactorStringLiteral, TEBNFFactor(TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).Factors[0]).NodeType, 'Inner factor type');
+  CheckEquals('optional_part', TEBNFFactor(TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).Factors[0]).Value, 'Inner factor value');
+end;
+
+procedure TTestEBNFParser.TestRuleWithRepetitionGroup;
+var
+  EBNFSource: string;
+  Rule: TEBNFRule;
+  Expression: TEBNFExpression;
+  Term: TEBNFTerm;
+  Factor: TEBNFFactor;
+begin
+  EBNFSource := 'repeat_rule = { identifier_to_repeat } ;';
+  Parser := TEBNFParser.Create(EBNFSource);
+  Grammar := Parser.Parse;
+  Rule:=Grammar.Rules['repeat_rule'];
+  CheckNotNull(Rule, 'Expected rule "repeat_rule"');
+  Expression := TEBNFExpression(Rule.Expression);
+  Term := TEBNFTerm(Expression.Terms[0]);
+  Factor := TEBNFFactor(Term.Factors[0]);
+
+  CheckEquals(etFactorRepetition, Factor.NodeType, 'Factor should be a repetition group');
+  CheckNotNull(Factor.InnerNode, 'Repetition group should have an inner node');
+  CheckEquals(etExpression, Factor.InnerNode.NodeType, 'Inner node should be an expression');
+  CheckEquals(1, TEBNFExpression(Factor.InnerNode).ChildCount, 'Inner expression should have 1 term');
+  CheckEquals(1, TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).ChildCount, 'Inner term should have 1 factor');
+  CheckEquals(etFactorIdentifier, TEBNFFactor(TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).Factors[0]).NodeType, 'Inner factor type');
+  CheckEquals('identifier_to_repeat', TEBNFFactor(TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).Factors[0]).Value, 'Inner factor value');
+end;
+
+procedure TTestEBNFParser.TestRuleWithParenthesizedGroup;
+var
+  EBNFSource: string;
+  Rule: TEBNFRule;
+  Expression: TEBNFExpression;
+  Term: TEBNFTerm;
+  Factor: TEBNFFactor;
+begin
+  EBNFSource := 'group_rule = ( "part_one" | "part_two" ) ;';
+  Parser := TEBNFParser.Create(EBNFSource);
+  Grammar := Parser.Parse;
+  Rule:=Grammar.Rules['group_rule'];
+  CheckNotNull(Rule, 'Expected rule "group_rule"');
+  Expression := TEBNFExpression(Rule.Expression);
+  Term := TEBNFTerm(Expression.Terms[0]);
+  Factor := TEBNFFactor(Term.Factors[0]);
+
+  CheckEquals(etFactorGroup, Factor.NodeType, 'Factor should be a parenthesized group');
+  CheckNotNull(Factor.InnerNode, 'Group should have an inner node');
+  CheckEquals(etExpression, Factor.InnerNode.NodeType, 'Inner node should be an expression');
+  CheckEquals(2, TEBNFExpression(Factor.InnerNode).ChildCount, 'Inner expression should have 2 terms'); // Because of '|'
+  CheckEquals(1, TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).ChildCount, 'First inner term should have 1 factor');
+  CheckEquals('part_one', TEBNFFactor(TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[0]).Factors[0]).Value, 'First inner factor value');
+  CheckEquals(1, TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[1]).ChildCount, 'Second inner term should have 1 factor');
+  CheckEquals('part_two', TEBNFFactor(TEBNFTerm(TEBNFExpression(Factor.InnerNode).Terms[1]).Factors[0]).Value, 'Second inner factor value');
+end;
+
+procedure TTestEBNFParser.TestRuleWithSpecialSequence;
+var
+  EBNFSource: string;
+  Rule: TEBNFRule;
+  Expression: TEBNFExpression;
+  Term: TEBNFTerm;
+  Factor: TEBNFFactor;
+begin
+  EBNFSource := 'special_rule = ? "this is a comment" ? ;';
+  Parser := TEBNFParser.Create(EBNFSource);
+  Grammar := Parser.Parse;
+  Rule := Grammar.Rules['special_rule'];
+  CheckNotNull(Rule, 'Expected rule "special_rule"');
+  Expression := TEBNFExpression(Rule.Expression);
+  Term := TEBNFTerm(Expression.Terms[0]);
+  Factor := TEBNFFactor(Term.Factors[0]);
+
+  CheckEquals(etFactorSpecialSequence, Factor.NodeType, 'Factor should be a special sequence');
+  CheckEquals('this is a comment', Factor.Value, 'Special sequence value should match');
+end;
+
+procedure TTestEBNFParser.TestDuplicateRuleError;
+var
+  EBNFSource: string;
+
+begin
+  EBNFSource :=
+    'ruleA = "first" ;' + sLineBreak +
+    'ruleA = "second" ;'; // Duplicate rule definition
+  Parser := TEBNFParser.Create(EBNFSource);
+  try
+    Parser.Parse;
+    Fail('Expected an exception for duplicate rule');
+  except
+    on E: Exception do
+      Check(Pos('Duplicate rule identifier: ruleA', E.Message) > 0, 'Expected "Duplicate rule identifier" error message');
+  end;
+end;
+
+procedure TTestEBNFParser.TestMissingEqualsError;
+var
+  EBNFSource: string;
+begin
+  EBNFSource := 'rule_bad "literal" ;'; // Missing '='
+  Parser := TEBNFParser.Create(EBNFSource);
+  try
+    Parser.Parse;
+    Fail('Expected an exception for missing equals sign');
+  except
+    on E: Exception do
+      Check(Pos('Expected ttEquals, but found ttStringLiteral', E.Message) > 0, 'Expected "Expected ttEquals" error message');
+  end;
+end;
+
+procedure TTestEBNFParser.TestMissingSemicolonError;
+var
+  EBNFSource: string;
+begin
+  EBNFSource := 'rule_bad = "literal" '; // Missing ';'
+  Parser := TEBNFParser.Create(EBNFSource);
+  try
+    Parser.Parse;
+    Fail('Expected an exception for missing semicolon');
+  except
+    on E: Exception do
+      Check(Pos('Expected ttSemicolon, but found ttEOF', E.Message) > 0, 'Expected "Expected ttSemicolon" error message');
+  end;
+end;
+
+procedure TTestEBNFParser.TestUnexpectedTokenInFactorError;
+var
+  EBNFSource: string;
+begin
+  EBNFSource := 'rule_bad = = ;'; // '==' is not a valid factor
+  Parser := TEBNFParser.Create(EBNFSource);
+  try
+    Parser.Parse;
+    Fail('Expected an exception for unexpected token in factor');
+  except
+    on E: Exception do
+      Check(Pos('Unexpected token for factor: ttEquals', E.Message) > 0, 'Expected "Unexpected token for factor" error message');
+  end;
+end;
+
+initialization
+  RegisterTest(TTestEBNFParser);
+
+end.
+

+ 276 - 0
packages/fcl-ebnf/tests/utcscanner.pas

@@ -0,0 +1,276 @@
+{
+    This file is part of the Free Component Library (FCL)
+    Copyright (c) 2025 by Michael Van Canneyt ([email protected])
+
+    Test EBNF Scanner
+
+    See the file COPYING.FPC, included in this distribution,
+    for details about the copyright.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+ **********************************************************************}
+
+unit utcscanner;
+
+interface
+
+uses
+  sysutils, fpcunit, testregistry, ebnf.scanner;
+
+type
+
+  { TTestEBNFScanner }
+
+  TTestEBNFScanner = class(TTestCase)
+  private
+    FScanner: TEBNFScanner;
+    procedure CheckToken(aType: TEBNFTokenType; aValue: string; AMessage: string);
+  protected
+    procedure SetUp; override;
+    procedure TearDown; override;
+    procedure AssertEquals(const Msg : String; aExpected, aActual : TEBNFTokenType); overload;
+    procedure CheckEquals(aExpected, aActual : TEBNFTokenType; const Msg : String = ''); overload;
+    property Scanner : TEBNFScanner Read FScanner Write FScanner;
+  published
+    // Test methods for each token type
+    procedure TestIdentifier;
+    procedure TestStringLiteralSingleQuote;
+    procedure TestStringLiteralDoubleQuote;
+    procedure TestEquals;
+    procedure TestComment;
+    procedure TestPipe;
+    procedure TestOpenParen;
+    procedure TestCloseParen;
+    procedure TestOpenBracket;
+    procedure TestCloseBracket;
+    procedure TestOpenBrace;
+    procedure TestCloseBrace;
+    procedure TestSemicolon;
+    procedure TestQuestion;
+    procedure TestEOF;
+    procedure TestWhitespaceHandling;
+    procedure TestMultipleTokens;
+    procedure TestUnknownTokenError;
+    procedure TestUnterminatedStringError;
+  end;
+
+implementation
+
+uses typinfo;
+
+{ TTestEBNFScanner }
+
+procedure TTestEBNFScanner.SetUp;
+begin
+  inherited SetUp;
+  FreeAndNil(FScanner);
+end;
+
+procedure TTestEBNFScanner.TearDown;
+begin
+  FreeAndNil(FScanner);
+  inherited TearDown;
+end;
+
+procedure TTestEBNFScanner.AssertEquals(const Msg: String; aExpected, aActual: TEBNFTokenType);
+begin
+  AssertEquals(Msg,GetEnumName(typeInfo(TEBNFTokenType),ord(aExpected)),
+                  GetEnumName(typeInfo(TEBNFTokenType),ord(aActual)));
+end;
+
+procedure TTestEBNFScanner.CheckEquals(aExpected, aActual: TEBNFTokenType; const Msg: String);
+begin
+  AssertEquals(Msg,aExpected,aActual);
+end;
+
+procedure TTestEBNFScanner.CheckToken(aType : TEBNFTokenType; aValue : string; AMessage : string);
+
+var
+  Token: TToken;
+begin
+  Token := Scanner.GetNextToken;
+  CheckEquals(aType, Token.TokenType, 'Expected token type');
+  if aType<>ttEOF then
+    CheckEquals(aValue, Token.Value, 'Expected token value');
+end;
+
+procedure TTestEBNFScanner.TestIdentifier;
+
+begin
+  Scanner := TEBNFScanner.Create('myRuleName another_id Rule123');
+  CheckToken(ttIdentifier,'myRuleName', 'first identifier');
+  CheckToken(ttIdentifier,'another_id', 'second identifier');
+  CheckToken(ttIdentifier,'Rule123', 'third identifier');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestStringLiteralSingleQuote;
+
+begin
+  Scanner := TEBNFScanner.Create('''hello world'' ''a''');
+  CheckToken(ttStringLiteral,'hello world','first literal');
+  CheckToken(ttStringLiteral,'a','second literal');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestStringLiteralDoubleQuote;
+begin
+  Scanner := TEBNFScanner.Create('"another string" "123"');
+  CheckToken(ttStringLiteral,'another string','first literal');
+  CheckToken(ttStringLiteral,'123','second literal');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestEquals;
+
+begin
+  Scanner := TEBNFScanner.Create('=');
+  CheckToken(ttEquals,'','Equals');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestComment;
+
+begin
+  Scanner := TEBNFScanner.Create('(* some comment *) =');
+  CheckToken(ttEquals,'','Equals');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestPipe;
+
+begin
+  Scanner := TEBNFScanner.Create('|');
+  CheckToken(ttPipe,'','Pipe');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestOpenParen;
+
+begin
+  Scanner := TEBNFScanner.Create('(');
+  CheckToken(ttOpenParen,'','open parenthesis');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestCloseParen;
+
+begin
+  Scanner := TEBNFScanner.Create(')');
+  CheckToken(ttCloseParen,'','close parenthesis');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestOpenBracket;
+
+begin
+  Scanner := TEBNFScanner.Create('[');
+  CheckToken(ttOpenBracket,'','open bracket');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestCloseBracket;
+
+begin
+  Scanner := TEBNFScanner.Create(']');
+  CheckToken(ttCloseBracket,'','close bracket');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestOpenBrace;
+
+begin
+  Scanner := TEBNFScanner.Create('{');
+  CheckToken(ttOpenBrace,'','open brace');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestCloseBrace;
+
+begin
+  Scanner := TEBNFScanner.Create('}');
+  CheckToken(ttCloseBrace,'','close brace');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestSemicolon;
+
+begin
+  Scanner := TEBNFScanner.Create(';');
+  CheckToken(ttSemicolon,'','semicolon');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestQuestion;
+
+begin
+  Scanner := TEBNFScanner.Create('?');
+  CheckToken(ttQuestion,'','Question');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestEOF;
+
+begin
+  Scanner := TEBNFScanner.Create('');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestWhitespaceHandling;
+
+begin
+  Scanner := TEBNFScanner.Create(#13#10'  rule = "test" ;   ');
+  CheckToken(ttIdentifier,'rule','first');
+  CheckToken(ttEquals,'','second');
+  CheckToken(ttStringLiteral,'test','third');
+  CheckToken(ttsemicolon,'','fourth');
+  CheckToken(ttEOF,'', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestMultipleTokens;
+
+begin
+  Scanner := TEBNFScanner.Create('grammar = rule { "|" rule } ;');
+  CheckToken(ttIdentifier, 'grammar', 'first');
+  CheckToken(ttEquals,'','second');
+  CheckToken(ttIdentifier, 'rule', 'third');
+  CheckToken(ttOpenBrace, '','fourth');
+  CheckToken(ttStringLiteral, '|', 'fifth');
+  CheckToken(ttIdentifier, 'rule', 'sixth');
+  CheckToken(ttCloseBrace, '','seventh');
+  CheckToken(ttSemicolon, '','eighth');
+  CheckToken(ttEOF, '', 'EOF');
+end;
+
+procedure TTestEBNFScanner.TestUnknownTokenError;
+begin
+  Scanner := TEBNFScanner.Create('@');
+  try
+    Scanner.GetNextToken;
+    Fail('Expected an exception for unknown token');
+  except
+    on E: Exception do
+      Check(Pos('Unknown token: "@"', E.Message) > 0, 'Expected "Unknown token: "@"" error message');
+  end;
+end;
+
+procedure TTestEBNFScanner.TestUnterminatedStringError;
+
+begin
+  Scanner := TEBNFScanner.Create('''unterminated');
+  try
+    Scanner.GetNextToken;
+    Fail('Expected an exception for unterminated string');
+  except
+    on E: Exception do
+      Check(Pos('Unterminated string literal', E.Message) > 0, 'Expected "Unterminated string literal" error message');
+  end;
+end;
+
+initialization
+  RegisterTest(TTestEBNFScanner);
+end.
+

+ 285 - 0
packages/fcl-ebnf/tests/utctree.pas

@@ -0,0 +1,285 @@
+{
+    This file is part of the Free Component Library (FCL)
+    Copyright (c) 2025 by Michael Van Canneyt ([email protected])
+
+    Test EBNF AST elements
+
+    See the file COPYING.FPC, included in this distribution,
+    for details about the copyright.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+ **********************************************************************}
+
+unit utctree;
+
+interface
+
+uses
+  fpcunit, testregistry, SysUtils,
+  ebnf.tree;
+
+type
+  TTestEBNFElementsToString = class(TTestCase)
+  protected
+    procedure SetUp; override;
+    procedure TearDown; override;
+  published
+    procedure TestElementToString;
+    procedure TestRuleToString;
+    procedure TestExpressionToString;
+    procedure TestTermToString;
+    procedure TestFactorIdentifierToString;
+    procedure TestFactorStringLiteralToString;
+    procedure TestFactorOptionalToString;
+    procedure TestFactorRepetitionToString;
+    procedure TestFactorGroupToString;
+    procedure TestFactorSpecialSequenceToString;
+    procedure TestGrammarToString;
+  end;
+
+implementation
+
+{ TTestEBNFElementsToString }
+
+procedure TTestEBNFElementsToString.SetUp;
+begin
+  inherited SetUp;
+end;
+
+procedure TTestEBNFElementsToString.TearDown;
+begin
+  inherited TearDown;
+end;
+
+procedure TTestEBNFElementsToString.TestElementToString;
+var
+  Node: TEBNFElement;
+begin
+  Node := TEBNFElement.Create(etRule);
+  try
+    CheckEquals('Node Type: etRule', Node.ToString, 'Base AST Node ToString should show node type');
+  finally
+    Node.Free;
+  end;
+end;
+
+procedure TTestEBNFElementsToString.TestRuleToString;
+var
+  Rule: TEBNFRule;
+  Expression: TEBNFExpression;
+  Term: TEBNFTerm;
+  Factor: TEBNFFactor;
+begin
+  Factor := TEBNFFactor.Create(etFactorStringLiteral, 'keyword');
+  Term := TEBNFTerm.Create;
+  Term.AddFactor(Factor);
+  Expression := TEBNFExpression.Create;
+  Expression.AddTerm(Term);
+  Rule := TEBNFRule.Create('myRule', Expression); // Rule takes ownership of Expression
+
+  try
+    CheckEquals('myRule = "keyword";', Rule.ToString, 'Rule ToString should be "identifier = expression;"');
+  finally
+    Rule.Free;
+  end;
+end;
+
+procedure TTestEBNFElementsToString.TestExpressionToString;
+var
+  Expression: TEBNFExpression;
+  Term1, Term2: TEBNFTerm;
+  Factor1, Factor2: TEBNFFactor;
+begin
+  //  (id1) | (id2)
+  Factor1 := TEBNFFactor.Create(etFactorIdentifier, 'id1');
+  Term1 := TEBNFTerm.Create;
+  Term1.AddFactor(Factor1);
+
+  Factor2 := TEBNFFactor.Create(etFactorIdentifier, 'id2');
+  Term2 := TEBNFTerm.Create;
+  Term2.AddFactor(Factor2);
+
+  Expression := TEBNFExpression.Create;
+  Expression.AddTerm(Term1);
+  Expression.AddTerm(Term2);
+
+  try
+    CheckEquals('id1 | id2', Expression.ToString, 'Expression ToString should concatenate terms with |');
+  finally
+    Expression.Free;
+  end;
+end;
+
+procedure TTestEBNFElementsToString.TestTermToString;
+var
+  Term: TEBNFTerm;
+  Factor1, Factor2: TEBNFFactor;
+begin
+  //  factorA "literalB"
+  Factor1 := TEBNFFactor.Create(etFactorIdentifier, 'factorA');
+  Factor2 := TEBNFFactor.Create(etFactorStringLiteral, 'literalB');
+
+  Term := TEBNFTerm.Create;
+  Term.AddFactor(Factor1);
+  Term.AddFactor(Factor2);
+
+  try
+    CheckEquals('factorA "literalB"', Term.ToString, 'Term ToString should concatenate factors with space');
+  finally
+    Term.Free;
+  end;
+end;
+
+procedure TTestEBNFElementsToString.TestFactorIdentifierToString;
+var
+  Factor: TEBNFFactor;
+begin
+  Factor := TEBNFFactor.Create(etFactorIdentifier, 'myIdentifier');
+  try
+    CheckEquals('myIdentifier', Factor.ToString, 'Identifier factor ToString should return its value');
+  finally
+    Factor.Free;
+  end;
+end;
+
+procedure TTestEBNFElementsToString.TestFactorStringLiteralToString;
+var
+  Factor: TEBNFFactor;
+begin
+  Factor := TEBNFFactor.Create(etFactorStringLiteral, 'hello');
+  try
+    CheckEquals('"hello"', Factor.ToString, 'String literal factor ToString should return quoted value');
+  finally
+    Factor.Free;
+  end;
+end;
+
+procedure TTestEBNFElementsToString.TestFactorOptionalToString;
+var
+  Factor: TEBNFFactor;
+  InnerExpression: TEBNFExpression;
+  InnerTerm: TEBNFTerm;
+  InnerFactor: TEBNFFactor;
+begin
+  InnerFactor := TEBNFFactor.Create(etFactorIdentifier, 'optionalPart');
+  InnerTerm := TEBNFTerm.Create;
+  InnerTerm.AddFactor(InnerFactor);
+  InnerExpression := TEBNFExpression.Create;
+  InnerExpression.AddTerm(InnerTerm);
+
+  Factor := TEBNFFactor.Create(etFactorOptional, InnerExpression); // Factor takes ownership of InnerExpression
+  try
+    CheckEquals('[optionalPart]', Factor.ToString, 'Optional factor ToString should be [expression]');
+  finally
+    Factor.Free;
+  end;
+end;
+
+procedure TTestEBNFElementsToString.TestFactorRepetitionToString;
+var
+  Factor: TEBNFFactor;
+  InnerExpression: TEBNFExpression;
+  InnerTerm: TEBNFTerm;
+  InnerFactor: TEBNFFactor;
+begin
+  InnerFactor := TEBNFFactor.Create(etFactorStringLiteral, 'repeated');
+  InnerTerm := TEBNFTerm.Create;
+  InnerTerm.AddFactor(InnerFactor);
+  InnerExpression := TEBNFExpression.Create;
+  InnerExpression.AddTerm(InnerTerm);
+
+  Factor := TEBNFFactor.Create(etFactorRepetition, InnerExpression); // Factor takes ownership of InnerExpression
+  try
+    CheckEquals('{"repeated"}', Factor.ToString, 'Repetition factor ToString should be {expression}');
+  finally
+    Factor.Free;
+  end;
+end;
+
+procedure TTestEBNFElementsToString.TestFactorGroupToString;
+var
+  Factor: TEBNFFactor;
+  InnerExpression: TEBNFExpression;
+  InnerTerm1, InnerTerm2: TEBNFTerm;
+  InnerFactor1, InnerFactor2: TEBNFFactor;
+begin
+  InnerFactor1 := TEBNFFactor.Create(etFactorIdentifier, 'choiceA');
+  InnerTerm1 := TEBNFTerm.Create;
+  InnerTerm1.AddFactor(InnerFactor1);
+
+  InnerFactor2 := TEBNFFactor.Create(etFactorIdentifier, 'choiceB');
+  InnerTerm2 := TEBNFTerm.Create;
+  InnerTerm2.AddFactor(InnerFactor2);
+
+  InnerExpression := TEBNFExpression.Create;
+  InnerExpression.AddTerm(InnerTerm1);
+  InnerExpression.AddTerm(InnerTerm2);
+
+  Factor := TEBNFFactor.Create(etFactorGroup, InnerExpression); // Factor takes ownership of InnerExpression
+  try
+    CheckEquals('(choiceA | choiceB)', Factor.ToString, 'Group factor ToString should be (expression)');
+  finally
+    Factor.Free; // This will free InnerExpression, InnerTerm1, InnerTerm2, InnerFactor1, InnerFactor2
+  end;
+end;
+
+procedure TTestEBNFElementsToString.TestFactorSpecialSequenceToString;
+var
+  Factor: TEBNFFactor;
+begin
+  Factor := TEBNFFactor.Create(etFactorSpecialSequence, 'this is a comment');
+  try
+    CheckEquals('?this is a comment?', Factor.ToString, 'Special sequence factor ToString should be ?value?');
+  finally
+    Factor.Free;
+  end;
+end;
+
+procedure TTestEBNFElementsToString.TestGrammarToString;
+var
+  Grammar: TEBNFGrammar;
+  Rule1, Rule2: TEBNFRule;
+  Expr1, Expr2: TEBNFExpression;
+  Term1, Term2: TEBNFTerm;
+  Factor1, Factor2: TEBNFFactor;
+begin
+  Grammar := TEBNFGrammar.Create;
+
+  // Rule 1: ruleA = "litA" ;
+  Factor1 := TEBNFFactor.Create(etFactorStringLiteral, 'litA');
+  Term1 := TEBNFTerm.Create;
+  Term1.AddFactor(Factor1);
+  Expr1 := TEBNFExpression.Create;
+  Expr1.AddTerm(Term1);
+  Rule1 := TEBNFRule.Create('ruleA', Expr1);
+  Grammar.AddRule(Rule1);
+
+  // Rule 2: ruleB = idB ;
+  Factor2 := TEBNFFactor.Create(etFactorIdentifier, 'idB');
+  Term2 := TEBNFTerm.Create;
+  Term2.AddFactor(Factor2);
+  Expr2 := TEBNFExpression.Create;
+  Expr2.AddTerm(Term2);
+  Rule2 := TEBNFRule.Create('ruleB', Expr2);
+  Grammar.AddRule(Rule2);
+  try
+    CheckEquals(
+      'ruleA = "litA";' + sLineBreak +
+      sLineBreak +
+      'ruleB = idB;' + sLineBreak +
+      sLineBreak,
+      Grammar.ToString,
+      'Grammar ToString should list all rules with line breaks'
+    );
+  finally
+    Grammar.Free;
+  end;
+end;
+
+initialization
+  RegisterTest(TTestEBNFElementsToString);
+end.
+

+ 1 - 0
packages/fpmake_add.inc

@@ -163,6 +163,7 @@
   add_fcl_jsonschema(ADirectory+IncludeTrailingPathDelimiter('fcl-jsonschema'));
   add_fcl_openapi(ADirectory+IncludeTrailingPathDelimiter('fcl-openapi'));
   add_fcl_wit(ADirectory+IncludeTrailingPathDelimiter('fcl-wit'));
+  add_fcl_ebnf(ADirectory+IncludeTrailingPathDelimiter('fcl-ebnf'));
   add_fcl_yaml(ADirectory+IncludeTrailingPathDelimiter('fcl-yaml'));
   add_ptckvm(ADirectory+IncludeTrailingPathDelimiter('ptckvm'));
   add_fcl_fpterm(ADirectory+IncludeTrailingPathDelimiter('fcl-fpterm'));

+ 6 - 0
packages/fpmake_proc.inc

@@ -924,6 +924,12 @@ begin
 {$include fcl-wit/fpmake.pp}
 end;
 
+procedure add_fcl_ebnf(const ADirectory: string);
+begin
+  with Installer do
+{$include fcl-ebnf/fpmake.pp}
+end;
+
 procedure add_fcl_yaml(const ADirectory: string);
 begin
   with Installer do