templates support
This commit is contained in:
331
Inspectron.Epson.Templates/Language/Lexer.cs
Normal file
331
Inspectron.Epson.Templates/Language/Lexer.cs
Normal file
@@ -0,0 +1,331 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Inspectron.Epson.Templates.Language;
|
||||
|
||||
public class Lexer
|
||||
{
|
||||
private readonly string _source;
|
||||
private readonly List<string> _lines;
|
||||
private int _lineIndex;
|
||||
private int _columnIndex;
|
||||
private readonly List<Token> _tokens = new();
|
||||
private readonly List<LexerError> _errors = new();
|
||||
private bool _inMultiLineComment;
|
||||
private SourcePosition _multiLineCommentStart;
|
||||
|
||||
private static readonly Regex StyleStartRegex = new(@"^#([a-zA-Z0-9,:]+)#", RegexOptions.Compiled);
|
||||
private static readonly Regex BindingRegex = new(@"^\{([^}]+)\}", RegexOptions.Compiled);
|
||||
private static readonly Regex ColumnRegex = new(@"^\|(\d+)(?:,(\w+))?\|", RegexOptions.Compiled);
|
||||
|
||||
public Lexer(string source)
|
||||
{
|
||||
_source = source ?? throw new ArgumentNullException(nameof(source));
|
||||
_lines = _source.Split('\n').ToList();
|
||||
// Normalize line endings
|
||||
for (int i = 0; i < _lines.Count; i++)
|
||||
{
|
||||
_lines[i] = _lines[i].TrimEnd('\r');
|
||||
}
|
||||
}
|
||||
|
||||
public LexerResult Tokenize()
|
||||
{
|
||||
_tokens.Clear();
|
||||
_errors.Clear();
|
||||
_lineIndex = 0;
|
||||
_columnIndex = 0;
|
||||
_inMultiLineComment = false;
|
||||
_multiLineCommentStart = default;
|
||||
|
||||
while (_lineIndex < _lines.Count)
|
||||
{
|
||||
TokenizeLine(_lines[_lineIndex]);
|
||||
_tokens.Add(new Token(TokenType.NewLine, "\n", new SourcePosition(_lineIndex + 1, _columnIndex + 1)));
|
||||
_lineIndex++;
|
||||
_columnIndex = 0;
|
||||
}
|
||||
|
||||
if (_inMultiLineComment)
|
||||
{
|
||||
_errors.Add(new LexerError("Unclosed multi-line comment", _multiLineCommentStart));
|
||||
}
|
||||
|
||||
_tokens.Add(Token.Eof(_lineIndex + 1));
|
||||
return new LexerResult(_tokens.ToList(), _errors.ToList());
|
||||
}
|
||||
|
||||
private void TokenizeLine(string line)
|
||||
{
|
||||
int lineNumber = _lineIndex + 1;
|
||||
|
||||
// Handle multi-line comment state
|
||||
if (_inMultiLineComment)
|
||||
{
|
||||
var closeIndex = line.IndexOf("*@", StringComparison.Ordinal);
|
||||
if (closeIndex >= 0)
|
||||
{
|
||||
// Found comment closer - exit comment mode
|
||||
_inMultiLineComment = false;
|
||||
var afterCloseIndex = closeIndex + 2;
|
||||
if (afterCloseIndex < line.Length && !string.IsNullOrWhiteSpace(line[afterCloseIndex..]))
|
||||
{
|
||||
// Process content after the closing *@
|
||||
_columnIndex = afterCloseIndex;
|
||||
TokenizeContent(line, lineNumber);
|
||||
}
|
||||
else
|
||||
{
|
||||
_columnIndex = line.Length;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Still in comment - skip entire line
|
||||
_columnIndex = line.Length;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for comment lines (must be checked before separators)
|
||||
var trimmed = line.TrimStart();
|
||||
var leadingWhitespace = line.Length - trimmed.Length;
|
||||
|
||||
if (trimmed.StartsWith("@*"))
|
||||
{
|
||||
// Check for inline comment: @* ... *@
|
||||
var closeIndex = trimmed.IndexOf("*@", 2, StringComparison.Ordinal);
|
||||
if (closeIndex >= 0)
|
||||
{
|
||||
// Inline single-line comment - skip entire comment portion
|
||||
var afterCloseIndex = leadingWhitespace + closeIndex + 2;
|
||||
if (afterCloseIndex < line.Length && !string.IsNullOrWhiteSpace(line[afterCloseIndex..]))
|
||||
{
|
||||
// Process content after the closing *@
|
||||
_columnIndex = afterCloseIndex;
|
||||
TokenizeContent(line, lineNumber);
|
||||
}
|
||||
else
|
||||
{
|
||||
_columnIndex = line.Length;
|
||||
}
|
||||
}
|
||||
else if (trimmed.TrimEnd() == "@*")
|
||||
{
|
||||
// Start of multi-line comment block
|
||||
_inMultiLineComment = true;
|
||||
_multiLineCommentStart = new SourcePosition(lineNumber, leadingWhitespace + 1);
|
||||
_columnIndex = line.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single-line comment to end of line
|
||||
_columnIndex = line.Length;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for separator lines (entire line)
|
||||
if (IsSeparatorLine(line, out var separatorType))
|
||||
{
|
||||
_tokens.Add(new Token(separatorType, line, new SourcePosition(lineNumber, 1)));
|
||||
_columnIndex = line.Length;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for directive lines
|
||||
if (trimmed.StartsWith("@"))
|
||||
{
|
||||
if (TryTokenizeDirective(trimmed, leadingWhitespace, lineNumber))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, process as content line
|
||||
TokenizeContent(line, lineNumber);
|
||||
}
|
||||
|
||||
private bool IsSeparatorLine(string line, out TokenType separatorType)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
separatorType = TokenType.Text;
|
||||
|
||||
if (trimmed.Length >= 3)
|
||||
{
|
||||
if (trimmed.All(c => c == '-'))
|
||||
{
|
||||
separatorType = TokenType.DashSeparator;
|
||||
return true;
|
||||
}
|
||||
if (trimmed.All(c => c == '='))
|
||||
{
|
||||
separatorType = TokenType.EqualsSeparator;
|
||||
return true;
|
||||
}
|
||||
if (trimmed.All(c => c == '*'))
|
||||
{
|
||||
separatorType = TokenType.StarSeparator;
|
||||
return true;
|
||||
}
|
||||
if (trimmed.All(c => c == '~'))
|
||||
{
|
||||
separatorType = TokenType.TildeSeparator;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryTokenizeDirective(string trimmed, int leadingWhitespace, int lineNumber)
|
||||
{
|
||||
var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 0) return false;
|
||||
|
||||
var directive = parts[0].ToLowerInvariant();
|
||||
var value = parts.Length > 1 ? string.Join(" ", parts.Skip(1)) : string.Empty;
|
||||
var position = new SourcePosition(lineNumber, leadingWhitespace + 1);
|
||||
|
||||
switch (directive)
|
||||
{
|
||||
case "@if":
|
||||
_tokens.Add(new Token(TokenType.If, value, position));
|
||||
_columnIndex = trimmed.Length + leadingWhitespace;
|
||||
return true;
|
||||
|
||||
case "@elseif":
|
||||
_tokens.Add(new Token(TokenType.ElseIf, value, position));
|
||||
_columnIndex = trimmed.Length + leadingWhitespace;
|
||||
return true;
|
||||
|
||||
case "@else":
|
||||
_tokens.Add(new Token(TokenType.Else, string.Empty, position));
|
||||
_columnIndex = trimmed.Length + leadingWhitespace;
|
||||
return true;
|
||||
|
||||
case "@end":
|
||||
_tokens.Add(new Token(TokenType.End, string.Empty, position));
|
||||
_columnIndex = trimmed.Length + leadingWhitespace;
|
||||
return true;
|
||||
|
||||
case "@foreach":
|
||||
_tokens.Add(new Token(TokenType.Foreach, value, position));
|
||||
_columnIndex = trimmed.Length + leadingWhitespace;
|
||||
return true;
|
||||
|
||||
case "@row":
|
||||
_tokens.Add(new Token(TokenType.Row, string.Empty, position));
|
||||
_columnIndex = trimmed.Length + leadingWhitespace;
|
||||
return true;
|
||||
|
||||
case "@endrow":
|
||||
_tokens.Add(new Token(TokenType.EndRow, string.Empty, position));
|
||||
_columnIndex = trimmed.Length + leadingWhitespace;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void TokenizeContent(string line, int lineNumber)
|
||||
{
|
||||
var textBuffer = new StringBuilder();
|
||||
int textStartColumn = 1;
|
||||
bool inStyle = false;
|
||||
|
||||
while (_columnIndex < line.Length)
|
||||
{
|
||||
var remaining = line[_columnIndex..];
|
||||
var currentColumn = _columnIndex + 1;
|
||||
|
||||
// Check for style start: #styles#
|
||||
if (!inStyle)
|
||||
{
|
||||
var styleMatch = StyleStartRegex.Match(remaining);
|
||||
if (styleMatch.Success)
|
||||
{
|
||||
FlushTextBuffer(textBuffer, lineNumber, textStartColumn);
|
||||
_tokens.Add(new Token(TokenType.StyleStart, styleMatch.Groups[1].Value,
|
||||
new SourcePosition(lineNumber, currentColumn)));
|
||||
_columnIndex += styleMatch.Length;
|
||||
textStartColumn = _columnIndex + 1;
|
||||
inStyle = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for style end: trailing #
|
||||
if (inStyle && remaining.StartsWith("#"))
|
||||
{
|
||||
FlushTextBuffer(textBuffer, lineNumber, textStartColumn);
|
||||
_tokens.Add(new Token(TokenType.StyleEnd, "#",
|
||||
new SourcePosition(lineNumber, currentColumn)));
|
||||
_columnIndex++;
|
||||
textStartColumn = _columnIndex + 1;
|
||||
inStyle = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for binding: {path} or {path:format}
|
||||
var bindingMatch = BindingRegex.Match(remaining);
|
||||
if (bindingMatch.Success)
|
||||
{
|
||||
FlushTextBuffer(textBuffer, lineNumber, textStartColumn);
|
||||
_tokens.Add(new Token(TokenType.Binding, bindingMatch.Groups[1].Value,
|
||||
new SourcePosition(lineNumber, currentColumn)));
|
||||
_columnIndex += bindingMatch.Length;
|
||||
textStartColumn = _columnIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for column: |width| or |width,align|
|
||||
var columnMatch = ColumnRegex.Match(remaining);
|
||||
if (columnMatch.Success)
|
||||
{
|
||||
FlushTextBuffer(textBuffer, lineNumber, textStartColumn);
|
||||
var width = columnMatch.Groups[1].Value;
|
||||
var align = columnMatch.Groups[2].Success ? columnMatch.Groups[2].Value : "left";
|
||||
_tokens.Add(new Token(TokenType.Column, $"{width},{align}",
|
||||
new SourcePosition(lineNumber, currentColumn)));
|
||||
_columnIndex += columnMatch.Length;
|
||||
textStartColumn = _columnIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular text character
|
||||
if (textBuffer.Length == 0)
|
||||
{
|
||||
textStartColumn = currentColumn;
|
||||
}
|
||||
textBuffer.Append(line[_columnIndex]);
|
||||
_columnIndex++;
|
||||
}
|
||||
|
||||
// Flush remaining text
|
||||
FlushTextBuffer(textBuffer, lineNumber, textStartColumn);
|
||||
|
||||
// If style wasn't closed on this line, add error
|
||||
if (inStyle)
|
||||
{
|
||||
_errors.Add(new LexerError("Unclosed style block", new SourcePosition(lineNumber, _columnIndex)));
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushTextBuffer(StringBuilder buffer, int line, int startColumn)
|
||||
{
|
||||
if (buffer.Length > 0)
|
||||
{
|
||||
_tokens.Add(new Token(TokenType.Text, buffer.ToString(),
|
||||
new SourcePosition(line, startColumn)));
|
||||
buffer.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record LexerResult(List<Token> Tokens, List<LexerError> Errors)
|
||||
{
|
||||
public bool HasErrors => Errors.Count > 0;
|
||||
}
|
||||
|
||||
public record LexerError(string Message, SourcePosition Position);
|
||||
14
Inspectron.Epson.Templates/Language/Nodes/BindingNode.cs
Normal file
14
Inspectron.Epson.Templates/Language/Nodes/BindingNode.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
public record BindingNode(string Path, string? Format, SourcePosition Position) : ITemplateNode
|
||||
{
|
||||
public IReadOnlyList<ITemplateNode> Children => Array.Empty<ITemplateNode>();
|
||||
|
||||
public static BindingNode Parse(string value, SourcePosition position)
|
||||
{
|
||||
var parts = value.Split(':', 2);
|
||||
var path = parts[0].Trim();
|
||||
var format = parts.Length > 1 ? parts[1].Trim() : null;
|
||||
return new BindingNode(path, format, position);
|
||||
}
|
||||
}
|
||||
27
Inspectron.Epson.Templates/Language/Nodes/ColumnNode.cs
Normal file
27
Inspectron.Epson.Templates/Language/Nodes/ColumnNode.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
public enum ColumnAlignment
|
||||
{
|
||||
Left,
|
||||
Right,
|
||||
Center
|
||||
}
|
||||
|
||||
public record ColumnNode(
|
||||
int Width,
|
||||
ColumnAlignment Alignment,
|
||||
List<ITemplateNode> ContentNodes,
|
||||
SourcePosition Position) : ITemplateNode
|
||||
{
|
||||
public IReadOnlyList<ITemplateNode> Children => ContentNodes;
|
||||
|
||||
public static (int Width, ColumnAlignment Alignment) ParseColumnSpec(string value)
|
||||
{
|
||||
var parts = value.Split(',', 2);
|
||||
var width = int.Parse(parts[0]);
|
||||
var align = parts.Length > 1
|
||||
? Enum.TryParse<ColumnAlignment>(parts[1], true, out var a) ? a : ColumnAlignment.Left
|
||||
: ColumnAlignment.Left;
|
||||
return (width, align);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
public record EmptyLineNode(SourcePosition Position) : ITemplateNode
|
||||
{
|
||||
public IReadOnlyList<ITemplateNode> Children => Array.Empty<ITemplateNode>();
|
||||
}
|
||||
21
Inspectron.Epson.Templates/Language/Nodes/ForeachNode.cs
Normal file
21
Inspectron.Epson.Templates/Language/Nodes/ForeachNode.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
public record ForeachNode(
|
||||
string ItemVariable,
|
||||
string CollectionPath,
|
||||
List<ITemplateNode> Body,
|
||||
SourcePosition Position) : ITemplateNode
|
||||
{
|
||||
public IReadOnlyList<ITemplateNode> Children => Body;
|
||||
|
||||
public static (string ItemVariable, string CollectionPath) ParseForeach(string value)
|
||||
{
|
||||
// Expected format: "item in collection" or "item in collection.path"
|
||||
var parts = value.Split(" in ", 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
throw new FormatException($"Invalid foreach syntax: '{value}'. Expected 'item in collection'.");
|
||||
}
|
||||
return (parts[0], parts[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
public interface ITemplateNode
|
||||
{
|
||||
SourcePosition Position { get; }
|
||||
IReadOnlyList<ITemplateNode> Children { get; }
|
||||
}
|
||||
31
Inspectron.Epson.Templates/Language/Nodes/IfNode.cs
Normal file
31
Inspectron.Epson.Templates/Language/Nodes/IfNode.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
public record ConditionBranch(
|
||||
string Condition,
|
||||
List<ITemplateNode> Body,
|
||||
SourcePosition Position);
|
||||
|
||||
public record IfNode(
|
||||
ConditionBranch IfBranch,
|
||||
IReadOnlyList<ConditionBranch> ElseIfBranches,
|
||||
List<ITemplateNode>? ElseBranch,
|
||||
SourcePosition Position) : ITemplateNode
|
||||
{
|
||||
public IReadOnlyList<ITemplateNode> Children
|
||||
{
|
||||
get
|
||||
{
|
||||
var children = new List<ITemplateNode>();
|
||||
children.AddRange(IfBranch.Body);
|
||||
foreach (var branch in ElseIfBranches)
|
||||
{
|
||||
children.AddRange(branch.Body);
|
||||
}
|
||||
if (ElseBranch != null)
|
||||
{
|
||||
children.AddRange(ElseBranch);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
}
|
||||
}
|
||||
6
Inspectron.Epson.Templates/Language/Nodes/RowNode.cs
Normal file
6
Inspectron.Epson.Templates/Language/Nodes/RowNode.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
public record RowNode(List<ColumnNode> Columns, SourcePosition Position) : ITemplateNode
|
||||
{
|
||||
public IReadOnlyList<ITemplateNode> Children => Columns;
|
||||
}
|
||||
14
Inspectron.Epson.Templates/Language/Nodes/SeparatorNode.cs
Normal file
14
Inspectron.Epson.Templates/Language/Nodes/SeparatorNode.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
public enum SeparatorStyle
|
||||
{
|
||||
Dash, // ---
|
||||
Equals, // ===
|
||||
Star, // ***
|
||||
Tilde // ~~~
|
||||
}
|
||||
|
||||
public record SeparatorNode(SeparatorStyle Style, SourcePosition Position) : ITemplateNode
|
||||
{
|
||||
public IReadOnlyList<ITemplateNode> Children => Array.Empty<ITemplateNode>();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
public record StyledTextNode(
|
||||
IReadOnlyList<string> Styles,
|
||||
List<ITemplateNode> ContentNodes,
|
||||
SourcePosition Position) : ITemplateNode
|
||||
{
|
||||
public IReadOnlyList<ITemplateNode> Children => ContentNodes;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
public record TemplateNode(List<ITemplateNode> ChildNodes) : ITemplateNode
|
||||
{
|
||||
public SourcePosition Position => new(1, 1);
|
||||
public IReadOnlyList<ITemplateNode> Children => ChildNodes;
|
||||
}
|
||||
6
Inspectron.Epson.Templates/Language/Nodes/TextNode.cs
Normal file
6
Inspectron.Epson.Templates/Language/Nodes/TextNode.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
public record TextNode(string Text, SourcePosition Position) : ITemplateNode
|
||||
{
|
||||
public IReadOnlyList<ITemplateNode> Children => Array.Empty<ITemplateNode>();
|
||||
}
|
||||
377
Inspectron.Epson.Templates/Language/Parser.cs
Normal file
377
Inspectron.Epson.Templates/Language/Parser.cs
Normal file
@@ -0,0 +1,377 @@
|
||||
using Inspectron.Epson.Templates.Language.Nodes;
|
||||
|
||||
namespace Inspectron.Epson.Templates.Language;
|
||||
|
||||
public class Parser
|
||||
{
|
||||
private readonly List<Token> _tokens;
|
||||
private int _position;
|
||||
private readonly List<ParseError> _errors = new();
|
||||
|
||||
public Parser(List<Token> tokens)
|
||||
{
|
||||
_tokens = tokens ?? throw new ArgumentNullException(nameof(tokens));
|
||||
}
|
||||
|
||||
public ParseResult Parse()
|
||||
{
|
||||
_position = 0;
|
||||
_errors.Clear();
|
||||
|
||||
var nodes = ParseNodeList(TokenType.EndOfFile);
|
||||
return new ParseResult(new TemplateNode(nodes), _errors.ToList());
|
||||
}
|
||||
|
||||
private List<ITemplateNode> ParseNodeList(params TokenType[] terminators)
|
||||
{
|
||||
var nodes = new List<ITemplateNode>();
|
||||
|
||||
while (!IsAtEnd() && !terminators.Contains(Current.Type))
|
||||
{
|
||||
var node = ParseNode();
|
||||
if (node != null)
|
||||
{
|
||||
nodes.Add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private ITemplateNode? ParseNode()
|
||||
{
|
||||
var token = Current;
|
||||
|
||||
switch (token.Type)
|
||||
{
|
||||
case TokenType.NewLine:
|
||||
Advance();
|
||||
// Check if this was an empty line (previous was also newline or start)
|
||||
if (_position >= 2 && _tokens[_position - 2].Type == TokenType.NewLine)
|
||||
{
|
||||
return new EmptyLineNode(token.Position);
|
||||
}
|
||||
return null;
|
||||
|
||||
case TokenType.Text:
|
||||
return ParseText();
|
||||
|
||||
case TokenType.StyleStart:
|
||||
return ParseStyledText();
|
||||
|
||||
case TokenType.Binding:
|
||||
return ParseBinding();
|
||||
|
||||
case TokenType.DashSeparator:
|
||||
Advance();
|
||||
return new SeparatorNode(SeparatorStyle.Dash, token.Position);
|
||||
|
||||
case TokenType.EqualsSeparator:
|
||||
Advance();
|
||||
return new SeparatorNode(SeparatorStyle.Equals, token.Position);
|
||||
|
||||
case TokenType.StarSeparator:
|
||||
Advance();
|
||||
return new SeparatorNode(SeparatorStyle.Star, token.Position);
|
||||
|
||||
case TokenType.TildeSeparator:
|
||||
Advance();
|
||||
return new SeparatorNode(SeparatorStyle.Tilde, token.Position);
|
||||
|
||||
case TokenType.If:
|
||||
return ParseIf();
|
||||
|
||||
case TokenType.Foreach:
|
||||
return ParseForeach();
|
||||
|
||||
case TokenType.Row:
|
||||
return ParseRow();
|
||||
|
||||
case TokenType.Column:
|
||||
return ParseColumn();
|
||||
|
||||
case TokenType.ElseIf:
|
||||
case TokenType.Else:
|
||||
case TokenType.End:
|
||||
case TokenType.EndRow:
|
||||
// These are handled by parent parsers; don't consume here
|
||||
return null;
|
||||
|
||||
case TokenType.StyleEnd:
|
||||
// Orphan style end - skip with error
|
||||
AddError("Unexpected style end marker '#' without matching style start", token.Position);
|
||||
Advance();
|
||||
return null;
|
||||
|
||||
case TokenType.EndOfFile:
|
||||
return null;
|
||||
|
||||
default:
|
||||
AddError($"Unexpected token type: {token.Type}", token.Position);
|
||||
Advance();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private TextNode ParseText()
|
||||
{
|
||||
var token = Current;
|
||||
Advance();
|
||||
return new TextNode(token.Value, token.Position);
|
||||
}
|
||||
|
||||
private BindingNode ParseBinding()
|
||||
{
|
||||
var token = Current;
|
||||
Advance();
|
||||
return BindingNode.Parse(token.Value, token.Position);
|
||||
}
|
||||
|
||||
private StyledTextNode ParseStyledText()
|
||||
{
|
||||
var startToken = Current;
|
||||
var styles = startToken.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
Advance(); // consume StyleStart
|
||||
|
||||
var contentNodes = new List<ITemplateNode>();
|
||||
|
||||
// Parse content until StyleEnd or NewLine
|
||||
while (!IsAtEnd() && Current.Type != TokenType.StyleEnd && Current.Type != TokenType.NewLine)
|
||||
{
|
||||
if (Current.Type == TokenType.Text)
|
||||
{
|
||||
contentNodes.Add(ParseText());
|
||||
}
|
||||
else if (Current.Type == TokenType.Binding)
|
||||
{
|
||||
contentNodes.Add(ParseBinding());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unexpected token inside styled text
|
||||
AddError($"Unexpected token inside styled text: {Current.Type}", Current.Position);
|
||||
Advance();
|
||||
}
|
||||
}
|
||||
|
||||
// Consume StyleEnd if present
|
||||
if (Current.Type == TokenType.StyleEnd)
|
||||
{
|
||||
Advance();
|
||||
}
|
||||
|
||||
return new StyledTextNode(styles, contentNodes, startToken.Position);
|
||||
}
|
||||
|
||||
private IfNode ParseIf()
|
||||
{
|
||||
var ifToken = Current;
|
||||
var ifCondition = ifToken.Value;
|
||||
Advance(); // consume @if
|
||||
|
||||
// Skip newline after @if
|
||||
SkipNewlines();
|
||||
|
||||
var ifBody = ParseNodeList(TokenType.ElseIf, TokenType.Else, TokenType.End, TokenType.EndOfFile);
|
||||
|
||||
var elseIfBranches = new List<ConditionBranch>();
|
||||
List<ITemplateNode>? elseBranch = null;
|
||||
|
||||
// Parse @elseif branches
|
||||
while (Current.Type == TokenType.ElseIf)
|
||||
{
|
||||
var elseIfToken = Current;
|
||||
var elseIfCondition = elseIfToken.Value;
|
||||
Advance(); // consume @elseif
|
||||
SkipNewlines();
|
||||
|
||||
var elseIfBody = ParseNodeList(TokenType.ElseIf, TokenType.Else, TokenType.End, TokenType.EndOfFile);
|
||||
elseIfBranches.Add(new ConditionBranch(elseIfCondition, elseIfBody, elseIfToken.Position));
|
||||
}
|
||||
|
||||
// Parse @else branch
|
||||
if (Current.Type == TokenType.Else)
|
||||
{
|
||||
Advance(); // consume @else
|
||||
SkipNewlines();
|
||||
|
||||
elseBranch = ParseNodeList(TokenType.End, TokenType.EndOfFile);
|
||||
}
|
||||
|
||||
// Expect @end
|
||||
if (Current.Type == TokenType.End)
|
||||
{
|
||||
Advance(); // consume @end
|
||||
}
|
||||
else
|
||||
{
|
||||
AddError("Expected @end to close @if block", Current.Position);
|
||||
}
|
||||
|
||||
return new IfNode(
|
||||
new ConditionBranch(ifCondition, ifBody, ifToken.Position),
|
||||
elseIfBranches,
|
||||
elseBranch,
|
||||
ifToken.Position);
|
||||
}
|
||||
|
||||
private ForeachNode ParseForeach()
|
||||
{
|
||||
var foreachToken = Current;
|
||||
Advance(); // consume @foreach
|
||||
|
||||
string itemVariable;
|
||||
string collectionPath;
|
||||
|
||||
try
|
||||
{
|
||||
(itemVariable, collectionPath) = ForeachNode.ParseForeach(foreachToken.Value);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
AddError(ex.Message, foreachToken.Position);
|
||||
itemVariable = "item";
|
||||
collectionPath = "items";
|
||||
}
|
||||
|
||||
SkipNewlines();
|
||||
|
||||
var body = ParseNodeList(TokenType.End, TokenType.EndOfFile);
|
||||
|
||||
if (Current.Type == TokenType.End)
|
||||
{
|
||||
Advance(); // consume @end
|
||||
}
|
||||
else
|
||||
{
|
||||
AddError("Expected @end to close @foreach block", Current.Position);
|
||||
}
|
||||
|
||||
return new ForeachNode(itemVariable, collectionPath, body, foreachToken.Position);
|
||||
}
|
||||
|
||||
private RowNode ParseRow()
|
||||
{
|
||||
var rowToken = Current;
|
||||
Advance(); // consume @row
|
||||
SkipNewlines();
|
||||
|
||||
var columns = new List<ColumnNode>();
|
||||
|
||||
// Parse columns until @endrow
|
||||
while (!IsAtEnd() && Current.Type != TokenType.EndRow && Current.Type != TokenType.EndOfFile)
|
||||
{
|
||||
if (Current.Type == TokenType.Column)
|
||||
{
|
||||
columns.Add(ParseColumnDef());
|
||||
}
|
||||
else if (Current.Type == TokenType.NewLine)
|
||||
{
|
||||
Advance();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Content outside of column definition - create implicit column
|
||||
var implicitContent = new List<ITemplateNode>();
|
||||
while (!IsAtEnd() &&
|
||||
Current.Type != TokenType.Column &&
|
||||
Current.Type != TokenType.EndRow &&
|
||||
Current.Type != TokenType.NewLine &&
|
||||
Current.Type != TokenType.EndOfFile)
|
||||
{
|
||||
var node = ParseNode();
|
||||
if (node != null) implicitContent.Add(node);
|
||||
}
|
||||
|
||||
if (implicitContent.Count > 0)
|
||||
{
|
||||
columns.Add(new ColumnNode(0, ColumnAlignment.Left, implicitContent, Current.Position));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Current.Type == TokenType.EndRow)
|
||||
{
|
||||
Advance(); // consume @endrow
|
||||
}
|
||||
else
|
||||
{
|
||||
AddError("Expected @endrow to close @row block", Current.Position);
|
||||
}
|
||||
|
||||
return new RowNode(columns, rowToken.Position);
|
||||
}
|
||||
|
||||
private ColumnNode ParseColumnDef()
|
||||
{
|
||||
var columnToken = Current;
|
||||
var (width, alignment) = ColumnNode.ParseColumnSpec(columnToken.Value);
|
||||
Advance(); // consume column token
|
||||
|
||||
var content = new List<ITemplateNode>();
|
||||
|
||||
// Parse content until next column, newline, or endrow
|
||||
while (!IsAtEnd() &&
|
||||
Current.Type != TokenType.Column &&
|
||||
Current.Type != TokenType.EndRow &&
|
||||
Current.Type != TokenType.NewLine &&
|
||||
Current.Type != TokenType.EndOfFile)
|
||||
{
|
||||
var node = ParseNode();
|
||||
if (node != null) content.Add(node);
|
||||
}
|
||||
|
||||
return new ColumnNode(width, alignment, content, columnToken.Position);
|
||||
}
|
||||
|
||||
private ColumnNode? ParseColumn()
|
||||
{
|
||||
// Standalone column outside of @row
|
||||
var columnToken = Current;
|
||||
var (width, alignment) = ColumnNode.ParseColumnSpec(columnToken.Value);
|
||||
Advance();
|
||||
|
||||
var content = new List<ITemplateNode>();
|
||||
|
||||
while (!IsAtEnd() &&
|
||||
Current.Type != TokenType.Column &&
|
||||
Current.Type != TokenType.NewLine &&
|
||||
Current.Type != TokenType.EndOfFile)
|
||||
{
|
||||
var node = ParseNode();
|
||||
if (node != null) content.Add(node);
|
||||
}
|
||||
|
||||
return new ColumnNode(width, alignment, content, columnToken.Position);
|
||||
}
|
||||
|
||||
private void SkipNewlines()
|
||||
{
|
||||
while (Current.Type == TokenType.NewLine)
|
||||
{
|
||||
Advance();
|
||||
}
|
||||
}
|
||||
|
||||
private Token Current => _position < _tokens.Count ? _tokens[_position] : Token.Eof(_tokens.Count);
|
||||
|
||||
private bool IsAtEnd() => _position >= _tokens.Count || Current.Type == TokenType.EndOfFile;
|
||||
|
||||
private void Advance()
|
||||
{
|
||||
if (!IsAtEnd()) _position++;
|
||||
}
|
||||
|
||||
private void AddError(string message, SourcePosition position)
|
||||
{
|
||||
_errors.Add(new ParseError(message, position));
|
||||
}
|
||||
}
|
||||
|
||||
public record ParseResult(TemplateNode Template, List<ParseError> Errors)
|
||||
{
|
||||
public bool HasErrors => Errors.Count > 0;
|
||||
}
|
||||
|
||||
public record ParseError(string Message, SourcePosition Position);
|
||||
44
Inspectron.Epson.Templates/Language/Token.cs
Normal file
44
Inspectron.Epson.Templates/Language/Token.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
namespace Inspectron.Epson.Templates.Language;
|
||||
|
||||
public enum TokenType
|
||||
{
|
||||
// Text content
|
||||
Text,
|
||||
|
||||
// Styled text: #style1,style2# text #
|
||||
StyleStart, // #style1,style2#
|
||||
StyleEnd, // trailing #
|
||||
|
||||
// Bindings: {path} or {path:format}
|
||||
Binding,
|
||||
|
||||
// Separators
|
||||
DashSeparator, // ---
|
||||
EqualsSeparator, // ===
|
||||
StarSeparator, // ***
|
||||
TildeSeparator, // ~~~
|
||||
|
||||
// Directives
|
||||
If, // @if condition
|
||||
ElseIf, // @elseif condition
|
||||
Else, // @else
|
||||
End, // @end
|
||||
Foreach, // @foreach item in collection
|
||||
Row, // @row
|
||||
EndRow, // @endrow
|
||||
Column, // |width| or |width,align|
|
||||
|
||||
// Structural
|
||||
NewLine,
|
||||
EndOfFile
|
||||
}
|
||||
|
||||
public record struct SourcePosition(int Line, int Column)
|
||||
{
|
||||
public override string ToString() => $"({Line}:{Column})";
|
||||
}
|
||||
|
||||
public record Token(TokenType Type, string Value, SourcePosition Position)
|
||||
{
|
||||
public static Token Eof(int line) => new(TokenType.EndOfFile, string.Empty, new SourcePosition(line, 0));
|
||||
}
|
||||
Reference in New Issue
Block a user