Foundationপ্রথম নীতি থেকে
LEVEL 5লেসন ২/১৬কঠিন৫৫ মিনিট

Lexical Analysis — character stream থেকে token stream

Lexical Analysis

Source text-কে অর্থপূর্ণ শব্দে ভাগ করার প্রথম compiler stage — token, maximal munch, DFA, আর নিজের হাতে লেখা পুরো Lexer class।

এই লেসন শেষে আপনি পারবেন

  • Token-এর structure (kind, value, line, col) ব্যাখ্যা করে বলতে পারবেন কেন position প্রতিটা token-এ রাখা জরুরি
  • Regex → NFA → DFA construction chain (Thompson construction, subset construction) একটা ছোট pattern-এ hand-trace করতে পারবেন
  • Maximal munch rule apply করে `>=`, `1.2e3`, `123abc` জাতীয় input-এ lexer-এর সিদ্ধান্ত বলতে পারবেন
  • Keyword-identifier problem-এর দুই সমাধান (reserved word list vs automaton paths) তুলনা করতে পারবেন
  • C-র typedef ambiguity কেন pure-regex lexer অসম্ভব করে আর feedback loop-টা কীভাবে চলে তা ব্যাখ্যা করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

আপনার 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উদাহরণকেন দরকার
kindINT, IDENT, PLUSParser type-ভিত্তিক decision নেয়
value"x", 10IDENT/INT-এর actual content
line7Error message — সবচেয়ে দামি field
col15Caret (^) 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 একই। দুই সমাধান:

  1. 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 invalid
Identifier-এর deterministic automaton। Double-circle = accepting; কোনো edge মিললে last accepting state-এ ফিরে token finalize।

NFA 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 সহ), continue

Textbook 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

CharActionToken
l,e,t then ident mode → keyword hitKW_LET (1,1)
x then ident start/endIDENT("x") (1,5)
=peek: next not =EQUALS (1,7)
4,2,;number end; ; consumed separatelyINT(42) (1,9), SEMI (1,11)
EOFdoneEOF (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
String scanning একটা ছোট FSM: body-loop, escape-detour, quote-শেষ। Unterminated = newline/EOF-এ বের না হয়ে error। Escape detour-টাই naive regex `"[^"]*"` পারে না — ভুল quote-এ শেষ করে।
              .                    e | E
  ──▶ (I1) ──────▶ ((I2)) ─ e/E ──▶ (I4) ─ sign?digit+ ─▶ ((I5))
    │ digit▲          │ digit
    └──self───────────┘

"1.2.3": FLOAT(1.2)-এর পর '.' — no outgoing edge → error ✓

নিজে চালিয়ে দেখুন

EXPERIMENT

Naive regex lexer বানাও, তারপর ভেঙে ফেলো

Linux / macOS (python3 only — no pip)· ২০ মিনিট

ধাপ ১ — 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, positioned

CPython state-machine — ঠিক আমরা build-এ যা বানাব।

এটা কী প্রমাণ করে

Combined regex lexer silent nonsense produce করে ঠিক সেই কেসগুলোতে যেখানে state/context দরকার — lexical analysis 'শুধু regex' না, state machine দরকার।

নিজে বানান

BUILD IT

ToyLang Lexer — positions, tests, পুরো ফাইল

Python · ●●●○○
  1. TokenType enum: keywords, operators, punctuation
  2. Lexer class: line/col track করে tokens() দেয়
  3. Ident scan → keyword-table lookup (reserved words)
  4. Multi-char ops peek দিয়ে: == != <= >= && ||
  5. // comments + whitespace skip (line counter সহ)
  6. Unterminated string / bad char → LexError with position
  7. __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 out

Inline 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

নিজে বাড়ান:

  1. Token-এ end_col — caret underline (^^^) diagnostics-এর ভিত্তি।
  2. 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-এর দায়িত্ব।

বুঝেছেন কি না দেখুন

1

Token-এর চার component আর প্রতিটার কাজ?

স্মরণ

kind — parser dispatch key; value — content; line/col — diagnostics আর IDE features। প্রথম দুটো compile করতে, শেষ দুটো ভুল বলতে — দুই-ই requirement।

2

a+++b C-তে কীভাবে tokenize হয়? Intent সম্ভবত কী ছিল?

প্রয়োগ

Maximal munch: a ++ + b। Programmer চেয়েছিল a + ++b — value ভিন্ন, warning-ও নেই (কিছু compiler -Wparentheses দেয়)।

3

Keyword 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 জেতে।

4

Typedef 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।

5

Bangla identifiers (যোগ = ৫ + ৩;) 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