using System.Text; using System.Text.RegularExpressions; namespace Inspectron.Epson.Templates.Language; public class Lexer { private readonly string _source; private readonly List _lines; private int _lineIndex; private int _columnIndex; private readonly List _tokens = new(); private readonly List _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 Tokens, List Errors) { public bool HasErrors => Errors.Count > 0; } public record LexerError(string Message, SourcePosition Position);