332 lines
11 KiB
C#
332 lines
11 KiB
C#
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);
|