cl-compiler / src /lexer.py
GitHub Actions
Sync from GitHub: a87d7bd
04307b3
Raw
History Blame Contribute Delete
6.12 kB
"""
Converts raw source text into a flat list of Token objects.
Algorithm: single-pass regex-based scanner.
1. Build one combined regex from ordered token patterns.
2. Walk through the source string left-to-right.
3. Each match produces a Token; whitespace/comments are skipped.
4. Unrecognised characters raise LexerError with line/column info.
Token types are defined in the TokenType enum so the parser can
reference them by name (e.g. TokenType.INT) rather than magic strings.
"""
import re
from enum import Enum, auto
from typing import List, NamedTuple
# Token type enumeration
class TokenType(Enum):
# --- Literals ---
INT_LIT = auto() # e.g. 42
STRING_LIT = auto() # e.g. "hello"
# --- Keywords ---
INT = auto() # 'int' (type keyword)
STRING = auto() # 'string' (type keyword)
IF = auto() # 'if'
THEN = auto() # 'then'
ELSE = auto() # 'else'
WHILE = auto() # 'while'
FOR = auto() # 'for'
TO = auto() # 'to' (used by the for-loop range)
# --- Identifier ---
IDENT = auto() # variable names: [a-zA-Z_][a-zA-Z0-9_]*
# --- Operators ---
PLUS = auto() # +
MINUS = auto() # -
STAR = auto() # *
EQUALS = auto() # = (assignment)
EQ = auto() # == (equality test)
GT = auto() # >
LT = auto() # <
# --- Delimiters ---
LPAREN = auto() # (
RPAREN = auto() # )
LBRACE = auto() # {
RBRACE = auto() # }
SEMICOLON = auto() # ;
# --- Special ---
EOF = auto() # end of token stream
# Token data class
class Token(NamedTuple):
"""
A single token produced by the lexer.
Attributes:
type : one of the TokenType enum values
value : the exact substring from the source
line : 1-based line number in the source file
col : 1-based column number of the first character
"""
type: TokenType
value: str
line: int
col: int
def __str__(self) -> str:
return f"Token({self.type.name:12s}, {self.value!r:15s}, line={self.line}, col={self.col})"
# Lexer error
class LexerError(Exception):
"""Raised when the lexer encounters an unrecognised character."""
pass
# Token patterns
# Keyword lookup table. Any IDENT that appears here is re-classified.
KEYWORDS: dict = {
"int": TokenType.INT,
"string": TokenType.STRING,
"if": TokenType.IF,
"then": TokenType.THEN,
"else": TokenType.ELSE,
"while": TokenType.WHILE,
"for": TokenType.FOR,
"to": TokenType.TO,
}
# Token patterns are tried in order; put longer/more specific patterns first.
# Each tuple is (regex_pattern, TokenType_or_None).
# None means "skip this match" (used for whitespace and comments).
TOKEN_PATTERNS: list = [
# Whitespace — skip
(r"[ \t\r\n]+", None),
# Single-line comments — skip
(r"//[^\n]*", None),
# String literal (handles \" escape inside the string)
(r'"(?:[^"\\]|\\.)*"', TokenType.STRING_LIT),
# Integer literal
(r"\d+", TokenType.INT_LIT),
# Identifiers and keywords (keywords are re-classified after match)
(r"[a-zA-Z_]\w*", TokenType.IDENT),
# Multi-character operators (must come before single-character ones
# so the longest match wins — '==' before '=').
(r"==", TokenType.EQ),
# Single-character tokens
(r"\+", TokenType.PLUS),
(r"-", TokenType.MINUS),
(r"\*", TokenType.STAR),
(r"=", TokenType.EQUALS),
(r">", TokenType.GT),
(r"<", TokenType.LT),
(r"\(", TokenType.LPAREN),
(r"\)", TokenType.RPAREN),
(r"\{", TokenType.LBRACE),
(r"\}", TokenType.RBRACE),
(r";", TokenType.SEMICOLON),
]
# Compile all patterns into a single master regex.
_MASTER_PATTERN = re.compile(
"|".join(f"({pat})" for pat, _ in TOKEN_PATTERNS)
)
# Lexer class
class Lexer:
"""
Tokenizes a source string into a list of Token objects.
Usage:
lexer = Lexer(source_code)
tokens = lexer.tokenize() # returns List[Token], last item is EOF
"""
def __init__(self, source: str) -> None:
self.source = source
def tokenize(self) -> List[Token]:
"""
Scan the entire source string and return a token list.
The final element is always Token(EOF, '', ...).
Raises LexerError for unrecognised characters.
"""
tokens: List[Token] = []
pos = 0
line = 1
line_start = 0 # character index where the current line started
while pos < len(self.source):
match = _MASTER_PATTERN.match(self.source, pos)
if not match:
bad_char = self.source[pos]
col = pos - line_start + 1
raise LexerError(
f"Unexpected character {bad_char!r} at line {line}, col {col}"
)
# Which pattern index matched?
group_index = next(
i for i, g in enumerate(match.groups()) if g is not None
)
token_type = TOKEN_PATTERNS[group_index][1]
matched_text = match.group(0)
col = pos - line_start + 1
if token_type is not None:
# Re-classify identifiers that are actually keywords
if token_type is TokenType.IDENT and matched_text in KEYWORDS:
token_type = KEYWORDS[matched_text]
tokens.append(Token(token_type, matched_text, line, col))
# Advance position; update line counter for newlines in the match
newlines = matched_text.count("\n")
if newlines:
line += newlines
line_start = pos + matched_text.rfind("\n") + 1
pos = match.end()
# Append synthetic EOF token
eof_col = pos - line_start + 1
tokens.append(Token(TokenType.EOF, "", line, eof_col))
return tokens