Lexical Analysis — character stream থেকে token stream
Lexical Analysis
Source text-কে অর্থপূর্ণ শব্দে ভাগ করার প্রথম compiler stage — token, maximal munch, DFA, আর নিজের হাতে লেখা পুরো Lexer class।
আগে এটা বুঝি
আপনার source ফাইল disk-এ আসলে কী? শুধু bytes — l, e, t,
, x, , =… Compiler-এর চোখে শুরুতে কোনো statement বা
meaning নেই। Character-এর stream।
আপনি নিজেও প্রতিদিন এই সমস্যা solve করেন: বাংলা পড়ার সময় চোখ শব্দে ভাগ করে পড়ে — “আমিভাতখাই” তিনটা শব্দ, space ছাড়াই। Compiler-এর প্রথম stage ঠিক সেই কাজ — lexical analysis: character stream → token stream:
let x = 10; ⇒ LET IDENT("x") EQUALS INT(10) SEMICOLONআজ দেখব: token আসলে কী, regex → NFA → DFA chain, practice-এ কেন
hand-written scanner — আর ToyLang-এর পুরো Lexer class, আমাদের
compiler-এর প্রথম ইট।
আজকের প্রশ্ন: কোন characters একসাথে কোন token? নিয়মটা formal করা যায় কী করে?
মূল ধারণা
Token = (kind, value, line, col)
Token শুধু string না — চার-tuple:
| Field | উদাহরণ | কেন দরকার |
|---|---|---|
kind | INT, IDENT, PLUS | Parser type-ভিত্তিক decision নেয় |
value | "x", 10 | IDENT/INT-এর actual content |
line | 7 | Error message — সবচেয়ে দামি field |
col | 15 | Caret (^) positioning |
line 7: let total = price * qty;
^
col 19: IDENT("price")Lexeme, pattern, token
Lexeme = raw substring (price); pattern = kind-এর নিয়ম
(letter-start identifier); token = matched lexeme-র structured
রূপ।
Maximal munch — longest match জেতে
Lexer-এর সোনার নিয়ম: যত লম্বা possible, তত লম্বা lexeme নাও।
>= → GTE এক token (GT + EQUALS নয়)
abc123 → IDENT("abc123")
1.2e3 → FLOAT(1200.0)
a=-1 → a = -1 (`=-` single token না)
a---b → a -- - b (decrement! `- --b` না)শেষ দুটোই ফাঁদ: maximal munch predictable, কিন্তু programmer-intent নয়। C-র style rule “unary minus-এ space” ঠিক এই জাতীয় bug থেকেই জন্ম।
Keyword বনাম identifier
if keyword নাকি identifier? Pattern-wise একই। দুই সমাধান:
- Reserved word list: সব IDENT হিসেবে scan, শেষে hash-set lookup — hit হলে keyword-kind return। O(1), নতুন keyword = list-এ string যোগ। Modern default। (Automaton embedding — flex- style — এক lookup বাঁচে কিন্তু generated code অপাঠ্য।)
আমরা পদ্ধতি ১ নেব।
Regex → NFA → DFA
Identifier pattern: [a-zA-Z_][a-zA-Z0-9_]*। Theory chain: regex →
mechanical নিয়মে NFA (Thompson construction, ~১৯৬৮) → subset
construction দিয়ে DFA:
letter | _
┌───────────────┐
▼ │
──▶ (S0) ──────────▶ ((S1)) ─┘
Running "sum_2": S0-'s'->S1-'u'->S1-'m'->S1-'_'->S1-'2'->S1 ✓
Running "2sum": S0-'2'-> DEAD → error: digit-start invalidNFA simulate ব্যয়বহুল (state-set tracking); DFA table-এ প্রতি character O(1) — পুরো input linear, no backtracking। এই asymptotic guarantee-টাই hand-written scanner-এর structural safety-র ব্যাখ্যা।
ভেতরে কী ঘটছে
Hand-written scanner-এর কাঠামো
Production compilers-এর বেশিরভাগ (Go, Rust, Clang, V8) hand-written scanner চালায় — table-এর বদলে explicit flow। মূল loop:
loop:
skip whitespace/comments ← ঘড়ি (line/col) update হয়
switch on current char:
digit → number scan (maximal munch)
letter|_ → ident scan → keyword lookup
'"' → string scan (escape state machine!)
'+','=' ... → multi-char op peek ('+' নাকি '+=')
EOF → EOF token; else → ERROR (position সহ), continueTextbook DFA যা বলে না, তিনটা reality: whitespace skipping আগে
(\n-এ line++, col=1; Python উল্টো whitespace-কেই INDENT/DEDENT
token বানায়); multi-char peek (= দেখলে next char check — ==?);
আর error-ও token — unterminated string-এ থামা না, report করে
পরের plausible token-এ jump। এক run-এ যত error তত report — production
contract।
C-র typedef ambiguity — handshake
typedef int T;
T x; // এখানে T = type nameকিন্তু অন্য ফাইলে int T; হলে? T * x-এর *-এর মানে (pointer
decl না multiplication) নির্ভর করে symbol table-এ T কী আছে তার
উপরে — যা semantic analysis জানে। C-র সমাধান: parser typedef দেখে
lexer-কে বলে “T এখন TYPE_NAME emit করো” — feedback loop।
একমাত্র এই কারণেই C lexer pure-regex হতে পারে না। ToyLang-এ boundary
clean: lexer fully self-contained — luxury, legacy grammar-এ পাওয়া
যায় না।
উদাহরণ
let limit = 42; — character by character
| Char | Action | Token |
|---|---|---|
l,e,t then | ident mode → keyword hit | KW_LET (1,1) |
x then | ident start/end | IDENT("x") (1,5) |
= | peek: next not = | EQUALS (1,7) |
4,2,; | number end; ; consumed separately | INT(42) (1,9), SEMI (1,11) |
| EOF | done | EOF (1,12) |
তিনটা subtle মুহূর্ত: =-এর peek (== হতে পারত); 42-এর শেষে
; consume না করা (peek-only); explicit EOF token — parser-
এর clean end-signal।
Maximal munch-এর সংকট
Input 42abc: maximal munch বলে INT(42) + IDENT("abc") —
কিন্তু এটা প্রায় সবসময় typo! ভালো lexer specific error দেয়:
“invalid suffix on numeric literal”। Maximal munch mechanical;
error policy আলাদা design choice। Number rules-ও ভাষাভেদে ভিন্ন:
Go-র 0x1p-2, Rust-র 1_000_000, C-র 1.e5 invalid — lexer
vocabulary-ই ভাষার fingerprint।
ছবিতে
String literal-এর state machine আর number DFA
any ≠ " \ \n \
─▶ (open) ────────────▶ (body) ───────┐
│ ▲ │ ▼
" (empty str) │ \ (esc)
▼ │ │ │ n t " ...
((done)) │ └──────────┘
\n or EOF
▼
ERROR: unterminated . e | E
──▶ (I1) ──────▶ ((I2)) ─ e/E ──▶ (I4) ─ sign?digit+ ─▶ ((I5))
│ digit▲ │ digit
└──self───────────┘
"1.2.3": FLOAT(1.2)-এর পর '.' — no outgoing edge → error ✓নিজে চালিয়ে দেখুন
Naive regex lexer বানাও, তারপর ভেঙে ফেলো
ধাপ ১ — combined-regex shortcut:
import re
TOKEN_RE = re.compile(r'''
(?P<INT>\d+) | (?P<IDENT>[A-Za-z_]\w*)
| (?P<STRING>"[^"]*") | (?P<OP>[+\-*/=;(){}]) | (?P<WS>\s+)
''', re.VERBOSE)
def tokenize(src):
tokens, pos = [], 0
while pos \< len(src):
m = TOKEN_RE.match(src, pos)
if not m:
raise SyntaxError(f"bad char at {pos}: {src[pos]!r}")
if m.lastgroup != "WS":
tokens.append((m.lastgroup, m.group()))
pos = m.end()
return tokens
print(tokenize('let x = 10;'))
# 'let' IDENT হলো — keyword না! আর line:col নেই, শুধু offsetধাপ ২ — তিনটা বোমা:
print(tokenize('x = "oops;')) # SyntaxError — ঠিক আছে, position vague
print(tokenize('1.2.3')) # [('INT','1'),('OP','.'),...] SILENT nonsense!
print(tokenize('a /* s */ b')) # comment rule-ই নেই — 's','*','/' tokensবোমা ২-ই শিক্ষণীয়: regex alternation context রাখে না — “dot-এর পরে dot illegal” বলা impossible; সেটা state। Compare CPython-এর আসল tokenizer:
import io, tokenize as tk
for tok in tk.generate_tokens(io.StringIO('x = 1.2.3\n').readline):
print(tok)
# TokenizeError: invalid decimal literal — precise, positionedCPython state-machine — ঠিক আমরা build-এ যা বানাব।
Combined regex lexer silent nonsense produce করে ঠিক সেই কেসগুলোতে যেখানে state/context দরকার — lexical analysis 'শুধু regex' না, state machine দরকার।
নিজে বানান
ToyLang Lexer — positions, tests, পুরো ফাইল
- TokenType enum: keywords, operators, punctuation
- Lexer class: line/col track করে tokens() দেয়
- Ident scan → keyword-table lookup (reserved words)
- Multi-char ops peek দিয়ে: == != <= >= && ||
- // comments + whitespace skip (line counter সহ)
- Unterminated string / bad char → LexError with position
- __main__-এ inline test suite
toylang/lexer.py — পরের সব lesson এর উপর দাঁড়াবে:
"""ToyLang lexer: character stream -> token stream."""
from dataclasses import dataclass
from enum import Enum, auto
class Tok(Enum):
# literals & names
INT = auto(); STRING = auto(); IDENT = auto()
# keywords
LET = auto(); IF = auto(); ELSE = auto(); WHILE = auto()
TRUE = auto(); FALSE = auto(); PRINT = auto()
# operators & punctuation
PLUS = auto(); MINUS = auto(); STAR = auto(); SLASH = auto()
PERCENT = auto(); BANG = auto()
EQEQ = auto(); BANGEQ = auto(); LTE = auto(); GTE = auto()
LT = auto(); GT = auto(); EQUALS = auto()
ANDAND = auto(); OROR = auto()
LPAREN = auto(); RPAREN = auto(); LBRACE = auto(); RBRACE = auto()
SEMI = auto()
EOF = auto()
KEYWORDS = {
"let": Tok.LET, "if": Tok.IF, "else": Tok.ELSE,
"while": Tok.WHILE, "true": Tok.TRUE, "false": Tok.FALSE,
"print": Tok.PRINT,
}
TWO_CHAR = {"==": Tok.EQEQ, "!=": Tok.BANGEQ,
"\<=": Tok.LTE, ">=": Tok.GTE,
"&&": Tok.ANDAND, "||": Tok.OROR}
ONE_CHAR = {"+": Tok.PLUS, "-": Tok.MINUS, "*": Tok.STAR,
"/": Tok.SLASH, "%": Tok.PERCENT, "!": Tok.BANG,
"=": Tok.EQUALS, "<": Tok.LT, ">": Tok.GT,
"(": Tok.LPAREN, ")": Tok.RPAREN,
"{": Tok.LBRACE, "}": Tok.RBRACE, ";": Tok.SEMI}
@dataclass(frozen=True)
class Token:
kind: Tok
value: str
line: int
col: int
class LexError(Exception):
def __init__(self, msg, line, col):
super().__init__(f"{msg} at {line}:{col}")
class Lexer:
def __init__(self, src: str):
self.src = src
self.pos = 0
self.line = 1
self.col = 1
def _peek(self, off=0):
i = self.pos + off
return self.src[i] if i \< len(self.src) else ""
def _advance(self) -> str:
ch = self.src[self.pos]
self.pos += 1
if ch == "\n":
self.line += 1
self.col = 1
else:
self.col += 1
return ch
def _skip_trivia(self):
while self.pos \< len(self.src):
ch = self._peek()
if ch in " \t\r\n":
self._advance()
elif ch == "/" and self._peek(1) == "/":
while self.pos \< len(self.src) and self._peek() != "\n":
self._advance()
else:
return
def _ident(self):
chars = []
while self._peek().isalnum() or self._peek() == "_":
chars.append(self._advance())
text = "".join(chars)
return KEYWORDS.get(text, Tok.IDENT), text
def _number(self):
chars = []
while self._peek().isdigit():
chars.append(self._advance())
if self._peek().isalpha() or self._peek() == "_":
raise LexError("invalid suffix on numeric literal",
self.line, self.col)
return Tok.INT, "".join(chars)
def _string(self):
self._advance() # opening quote
chars = []
escapes = {"n": "\n", "t": "\t", '"': '"'}
while True:
if self.pos >= len(self.src) or self._peek() == "\n":
raise LexError("unterminated string",
self.line, self.col)
ch = self._advance()
if ch == '"':
break
if ch == "\\":
esc = self._advance()
if esc not in escapes:
raise LexError(f"unknown escape \\{esc}",
self.line, self.col)
chars.append(escapes[esc])
else:
chars.append(ch)
return Tok.STRING, "".join(chars)
def next_token(self) -> Token:
self._skip_trivia()
line, col = self.line, self.col
if self.pos >= len(self.src):
return Token(Tok.EOF, "", line, col)
ch = self._peek()
if ch.isalpha() or ch == "_":
kind, val = self._ident()
elif ch.isdigit():
kind, val = self._number()
elif ch == '"':
kind, val = self._string()
else:
two = self.src[self.pos:self.pos + 2]
if two in TWO_CHAR:
self._advance(); self._advance()
return Token(TWO_CHAR[two], two, line, col)
if ch in ONE_CHAR:
self._advance()
return Token(ONE_CHAR[ch], ch, line, col)
raise LexError(f"unexpected character {ch!r}", line, col)
return Token(kind, val, line, col)
def tokens(self):
out = []
while True:
tok = self.next_token()
out.append(tok)
if tok.kind is Tok.EOF:
return outInline tests — ফাইলের শেষে:
def _test():
L = Lexer
assert L("let x = 10;").tokens() == [
Token(Tok.LET, "let", 1, 1),
Token(Tok.IDENT, "x", 1, 5),
Token(Tok.EQUALS, "=", 1, 7),
Token(Tok.INT, "10", 1, 9),
Token(Tok.SEMI, ";", 1, 11),
Token(Tok.EOF, "", 1, 12),
]
assert L("if x \<= 3 {}").tokens()[2].kind is Tok.LTE
assert L("a!=b").tokens()[1].value == "!="
assert L("true").tokens()[0].kind is Tok.TRUE
assert L("trueX").tokens()[0].kind is Tok.IDENT # maximal munch
assert L("// gone\nx").tokens()[0] == Token(Tok.IDENT, "x", 2, 1)
assert L('"a\\nb"').tokens()[0].value == "a\nb"
for bad in ['x = "oops', "42abc;", "x @ y;", '"a\\qb"']:
try:
L(bad).tokens()
raise AssertionError(f"no error for {bad!r}")
except LexError as e:
assert ":" in str(e)
print("all lexer tests passed")
if __name__ == "__main__":
_test()Expected:
$ python3 -m toylang.lexer
all lexer tests passedনিজে বাড়ান:
- Token-এ
end_col— caret underline (^^^) diagnostics-এর ভিত্তি। - Bangla identifiers: combining marks, ZWNJ, NFC/NFD normalization — bangla-encoding লেসনের সেই theory lexer-ই প্রথম practical pain।
বাস্তব সিস্টেমে
Production lexers
Go (go/scanner)। Hand-written, stdlib-readable keyword map —
শেখার জন্য সেরা production example। Rust (rustc_lexer) deliberately
minimal: raw tokens only, validation পরের stage-এ।
flex/re2c। Table generators — regex দিলে DFA table emit (PostgreSQL সহ countless tools); theory-র industrialization।
tree-sitter + V8। Incremental lexing (changed region-ই re-lex — VS Code highlighting) আর lazy lexing (function body দরকারে tokenize) — compile-time tricks lexer-এর খরচ নিয়েই।
যে ভুলগুলো সবাই করে
“Lexer শুধু ভাগ করে — ভুল হওয়ার সুযোগ নেই।”
উল্টো — lexical errors-ই user-এর প্রথম দেখা ভুল: unterminated
string, stray @, 1.2.3। Position tracking খারাপ হলে diagnostic
misleading: x = "oops report হয় string শুরুতে না, শেষে।
“Keyword-দের আলাদা TokenType mandatory।”
দুই valid design: separate kinds (আমাদের) বা সব IDENT, parser value-check করে। Trade-off: dispatch speed/exhaustiveness বনাম compact grammar table। Consistency-ই জরুরি।
“Regex দিয়েই সব tokenize — DFA theory বাড়তি।”
Regex lexical patterns-এ perfect (regular language)। কিন্তু nesting, balanced structures, typedef-context regular-এর বাইরে — আর experiment- এর silent failures state machine-এ structural errors হয়ে ধরা পড়ে।
“Maximal munch সবসময় intent মেনে চলে।”
a---b → a-- - b; পুরনো C dialect-এ x=y-1 ছিল x = y -= 1!
Predictable হওয়াটাই এর মেধা — correct-intent না। Edge cases document
করা designer-এর দায়িত্ব।
বুঝেছেন কি না দেখুন
1Token-এর চার component আর প্রতিটার কাজ?
স্মরণ
kind — parser dispatch key; value — content; line/col —
diagnostics আর IDE features। প্রথম দুটো compile করতে, শেষ দুটো
ভুল বলতে — দুই-ই requirement।
2a+++b C-তে কীভাবে tokenize হয়? Intent সম্ভবত কী ছিল?
প্রয়োগ
a+++b C-তে কীভাবে tokenize হয়? Intent সম্ভবত কী ছিল?Maximal munch: a ++ + b। Programmer চেয়েছিল a + ++b — value
ভিন্ন, warning-ও নেই (কিছু compiler -Wparentheses দেয়)।
3Keyword lookup scan-এর পরে কেন? Embedded-paths approach-এর
trade-off কী?
যুক্তি
Post-lookup: এক identifier automaton + O(1) hash lookup; নতুন keyword trivial। Embedded paths: এক lookup বাঁচে কিন্তু automaton ফুলে যায়, generated code অপাঠ্য। Modern CPU-তে hash এত fast যে difference measure-ই কঠিন — maintainability জেতে।
4Typedef feedback loop-এ কে কাকে কী জানায়? Regex কেন insufficient?
যুক্তি
Parser (sema-সহ) typedef দেখে lexer-কে জানায় “T now TYPE_NAME”;
lexer তখন থেকে সেভাবে emit করে। Token-kind নির্ভর করছে mutable
semantic state-এর উপর — regex-এ shared mutable state নেই, তাই
pure-regex impossible।
5Bangla identifiers (যোগ = ৫ + ৩;) support করতে _ident()-এ কী
বদলাবেন, কোন নতুন সমস্যা আসবে? আর "a\qb"-এ আমাদের Lexer-এর
behavior কী?
ডিজাইন
যোগ = ৫ + ৩;) support করতে _ident()-এ কী
বদলাবেন, কোন নতুন সমস্যা আসবে? আর "a\qb"-এ আমাদের Lexer-এর
behavior কী?isalpha() unicode-aware — Bangla letters pass করেই। আসল সমস্যা:
combining vowel-signs (start হওয়া উচিত না), invisible ZWNJ/ZWJ
(homoglyph), NFC/NFD normalization — explicit ranges + policy লাগবে।
\q: আমাদের version LexError (strict); alternative identity
passthrough (lenient) — দুটোই defensible, tests-এ case mandatory।
এরপর কী
এরপর কী
Token stream হাতে — কিন্তু flat list আর program-এর মধ্যে গর্ত:
1 + 2 * 3-এর tokens দেখে কে বলবে * আগে? Structure-এর নিয়ম
লেখা হয় grammar-এ — আর grammar-এর সবচেয়ে বিপজ্জনক ফাঁদ
ambiguity।
পরের লেসন: BNF/EBNF, derivation আর parse tree, dangling else, precedence layering (expr→term→factor), left recursion elimination। Theory-heavy, কিন্তু প্রতিটা ধারণা পরের লেসনের Pratt parser-এ directly কাজে লাগবে।
আরও পড়ুন
- Compilers: Principles, Techniques, and Tools (2nd ed.), Chapter 3 — Lexical Analysis — Aho, Sethi, Ullman, Lam (the Dragon Book) · Regex→NFA→DFA theory-র ক্যাননিক্যাল treatment — Thompson ও subset construction-এর মূল উৎস
- Crafting Interpreters, Chapter 4 — Scanning — Robert Nystrom · Hand-written lexer-এর সবচেয়ে পঠনীয় walkthrough — আমাদের build section-এর পদ্ধতির কাছাকাছি
- Engineering a Compiler (2nd ed.), Section 2.2 — Recognizing Words — Keith Cooper, Linda Torczon · Scanner কে DFA হিসেবে implement করার engineering view