Error leksikal muncul ketika lexer menemui karakter yang tidak dapat dipetakan ke kelas token mana pun. Topik ini membahas sumber error leksikal, cara menyusun pesan yang menjelaskan masalah, strategi pemulihan panic mode yang memungkinkan lexer melanjutkan pemindaian setelah satu kesalahan, dan pelaporan lokasi yang menyebut baris serta kolom. Pembaca memperoleh berkas lexer_errors.py yang memperluas scanner sebelumnya dengan antarmuka Lexer(source).errors() untuk mengambil daftar error leksikal.
Sumber error leksikal#
Bahasa mini v1 membatasi karakter yang dikenali lexer menjadi empat kelompok, yaitu karakter pembentuk identifier (huruf, digit, garis bawah), karakter pembentuk bilangan (digit dan titik), tanda kutip ganda untuk string, serta tanda operator dan pemisah yang sudah didaftarkan. Karakter di luar keempat kelompok ini tidak memiliki kelas token yang sesuai, sehingga lexer tidak dapat melanjutkan pemindaian secara normal. Whitespace seperti spasi, tab, dan baris baru bukan bagian dari keempat kelompok itu, tetapi sengaja dilewatkan alih-alih dilaporkan sebagai error.
Tiga jenis kesalahan pengguna paling sering memicu error leksikal. Pertama, karakter salah ketik yang bukan bagian alphabet, misalnya $, @, atau ?. Kedua, karakter yang berasal dari bahasa lain tetapi tidak ada di bahasa mini v1, misalnya tanda titik koma sebagai pemisah pernyataan, atau tanda panah -> untuk fungsi. Ketiga, karakter kontrol yang tidak terlihat, misalnya tabulasi vertikal atau escape ilegal pada string, yang dapat muncul ketika kode disalin dari sumber dengan penyandian berbeda.
Pesan error yang baik menyebut karakter penyebabnya, sehingga pengguna dapat menelusuri masalah secara langsung. Bandingkan pesan “error leksikal” dengan pesan “karakter tak dikenal $ pada 7:14”. Pesan pertama tidak memberi petunjuk apa pun, sedangkan pesan kedua menyebut karakter dan lokasi yang harus diperiksa pengguna.
Pesan yang menjelaskan masalah#
Pesan error leksikal dibangun dari tiga komponen, yaitu kategori kesalahan, karakter penyebab, dan lokasi pada kode sumber. Kategori menyatakan jenis masalah secara singkat, karakter menyatakan apa yang ditemui lexer, dan lokasi menyatakan di mana. Komponen lokasi dibahas pada bagian tersendiri, sehingga bagian ini memfokuskan kategori dan karakter.
Kategori dipilih dari daftar kecil yang stabil. Untuk topik ini, kategori tunggal “karakter tak dikenal” sudah cukup, karena satu-satunya kesalahan yang ditangani adalah karakter ilegal. Kategori lain seperti “string tidak ditutup” atau “komentar tidak diakhiri” baru relevan ketika lexer diperluas untuk memeriksa konteks yang lebih kompleks pada versi lanjutan. Konsistensi kategori memungkinkan tahap pelaporan menyaring dan mengelompokkan error, sehingga pengguna dapat membedakan kesalahan ketik dari kesalahan struktural.
Karakter penyebab disertakan dalam pesan dengan notasi repr Python, sehingga karakter noncetak dan karakter spesial terlihat jelas. Misalnya, karakter tabulasi horizontal muncul sebagai '\t' dan karakter $ muncul sebagai '$'. Penyertaan karakter penting karena beberapa karakter tidak terlihat saat dicetak polos, sehingga tanpa notasi repr, pengguna tidak dapat membedakan error yang disebabkan oleh tabulasi dari error yang disebabkan oleh spasi.
Pemulihan panic mode#
Pemulihan panic mode adalah strategi lexer melanjutkan pemindaian setelah menemui karakter ilegal, alih-alih menghentikan seluruh proses. Strategi ini memungkinkan satu kesalahan karakter tidak menyembunyikan kesalahan lain di baris berikutnya, sehingga pengguna dapat memperbaiki beberapa masalah dalam satu putaran penyuntingan. Strategi ini berbeda dari penanganan error pada parser yang biasanya membuang token hingga titik sinkronisasi, sebab lexer bekerja pada level karakter dan memiliki titik pemulihan alami pada karakter berikutnya yang dikenali.
Implementasi panic mode pada lexer_errors.py bekerja sebagai berikut. Saat loop utama menemui karakter tak dikenal, lexer mengumpulkan deret karakter ilegal yang berurutan, lalu melompati seluruhnya dengan satu pesan error. Lompatan berhenti ketika bertemu karakter yang dikenali lexer, yaitu whitespace, huruf, digit, garis bawah, tanda kutip, operator, atau pemisah. Setelah lompatan, loop utama kembali memeriksa karakter pada posisi baru.
Strategi ini berbeda dari panic mode yang hanya membuang satu karakter pada satu waktu. Bila hanya satu karakter yang dibuang, deret seperti $@ menghasilkan dua pesan error terpisah, padahal keduanya berasal dari satu kesalahan ketik. Bila seluruh deret dilewati sekaligus, satu pesan error sudah cukup, dan pesan itu menyebut seluruh deret sebagai satu unit yang perlu diperbaiki. Tradeoff yang muncul adalah pesan error menjadi lebih panjang bila deret karakter ilegal panjang, tetapi biaya ini lebih murah dibanding dua pesan untuk masalah yang sama.
Pelaporan lokasi#
Setiap error leksikal mencatat line dan column pada karakter pertama yang menyebabkan masalah, sama seperti token yang mencatat posisi pada awal lexeme. Konsistensi ini memungkinkan tahap pelaporan memformat pesan error dan pesan token dengan cara yang sama, sehingga antarmuka pengguna menjadi seragam. Posisi pada karakter pertama, bukan karakter terakhir pada deret ilegal, dipakai karena posisi awal lebih mudah ditelusuri pengguna saat menyunting kode sumber.
Pelaporan lokasi memerlukan dua langkah tambahan setelah pemindaian selesai. Pertama, lexer menyimpan daftar LexError selama pemindaian, sehingga antarmuka publik Lexer(source).errors() mengembalikan daftar itu tanpa menghitung ulang. Kedua, pesan yang dicetak ke pengguna mengikuti format tetap, misalnya error leksikal pada 7:14: karakter tak dikenal '$', sehingga pengguna dapat membaca baris dan kolom dengan cepat.
Daftar error disimpan terpisah dari daftar token. Pemisahan ini menjaga agar parser yang menerima list[Token] tidak perlu menyaring error dari token, sehingga kontrak antarmuka parser tetap sederhana. Tahap driver compiler bertugas menggabungkan kedua daftar itu ketika menyusun laporan akhir, misalnya mencetak error lebih dulu lalu token, atau memutuskan apakah pipeline berlanjut ke parser bila ada error.
Implementasi pada lexer#
Berkas lexer_errors.py berikut memperluas scanner sebelumnya dengan pemulihan error leksikal. Struktur LexError adalah namedtuple dengan field message, line, dan column. Antarmuka publik Lexer(source).tokens() tetap mengembalikan list[Token], sedangkan Lexer(source).errors() mengembalikan list[LexError].
"""Lexer dengan pemulihan error leksikal untuk bahasa mini v1.
Antarmuka publik:
Lexer(source).tokens() -> list[Token]
Lexer(source).errors() -> list[LexError]
Saat menemui karakter tak dikenal, lexer:
1. mencatat pesan error beserta posisi (baris, kolom)
2. melompati deret karakter ilegal hingga karakter yang dikenali
3. melanjutkan pemindaian dari posisi baru
Pemulihan ini memungkinkan satu kesalahan karakter tidak menggagalkan
seluruh proses, sehingga pembaca dapat melihat beberapa error sekaligus
dalam satu kali pemanggilan.
"""
from collections import namedtuple
Token = namedtuple("Token", ["type", "lexeme", "line", "column"])
LexError = namedtuple("LexError", ["message", "line", "column"])
KEYWORDS = frozenset({
"let", "print", "if", "else", "while",
"true", "false", "nil", "and", "or",
})
DUA_KARAKTER = {"<=", ">=", "==", "!="}
SATU_KARAKTER = set("+-*/<>!=")
PUNCT = set("(){}")
class Lexer:
"""Scanner tulis tangan dengan pemulihan error leksikal."""
def __init__(self, source: str):
self.src = source
self.pos = 0
self.line = 1
self.column = 1
self._errors: list[LexError] = []
# ----- utilitas -----
def _peek(self, offset: int = 0) -> str:
idx = self.pos + offset
if idx >= len(self.src):
return ""
return self.src[idx]
def _advance(self) -> str:
ch = self.src[self.pos]
self.pos += 1
if ch == "\n":
self.line += 1
self.column = 1
else:
self.column += 1
return ch
def _at_end(self) -> bool:
return self.pos >= len(self.src)
# ----- antarmuka publik -----
def tokens(self) -> list[Token]:
out: list[Token] = []
while not self._at_end():
ch = self._peek()
if ch in " \t\r\n":
self._advance()
continue
if ch == "/" and self._peek(1) == "/":
self._komentar()
continue
if ch.isalpha() or ch == "_":
out.append(self._identifier())
continue
if ch.isdigit():
out.append(self._angka())
continue
if ch == '"':
out.append(self._string())
continue
if ch in PUNCT:
line, col = self.line, self.column
self._advance()
out.append(Token("PUNCT", ch, line, col))
continue
if ch in SATU_KARAKTER:
out.append(self._operator())
continue
# Karakter tak dikenal: catat error, lalu lompati (panic mode).
self._error_karakter()
out.append(Token("EOF", "", self.line, self.column))
return out
def errors(self) -> list[LexError]:
return list(self._errors)
# ----- pemulihan error -----
def _dikenali(self, ch: str) -> bool:
"""Apakah karakter ini akan diproses oleh suatu cabang loop utama."""
if ch in " \t\r\n":
return True
if ch == "/":
return True # komentar atau operator pembagian
if ch.isalpha() or ch == "_":
return True
if ch.isdigit():
return True
if ch == '"':
return True
if ch in PUNCT or ch in SATU_KARAKTER:
return True
return False
def _error_karakter(self) -> None:
line, col = self.line, self.column
mulai = self.pos
# Panic mode: lompati deret karakter tak dikenal hingga karakter
# yang dikenali lexer (whitespace, huruf, digit, _, ", punct,
# atau operator). Strategi ini mengurangi pesan error untuk
# deret karakter ilegal sekaligus, sekaligus tidak membuang
# karakter valid yang mengikuti deret tersebut.
while not self._at_end() and not self._dikenali(self._peek()):
self._advance()
ilegal = self.src[mulai:self.pos]
self._errors.append(LexError(
f"karakter tak dikenal {ilegal!r}",
line, col,
))
# ----- pemindai per kelas -----
def _komentar(self) -> None:
self._advance() # '/'
self._advance() # '/'
while not self._at_end() and self._peek() != "\n":
self._advance()
def _identifier(self) -> Token:
line, col = self.line, self.column
start = self.pos
while not self._at_end() and (self._peek().isalnum() or self._peek() == "_"):
self._advance()
lex = self.src[start:self.pos]
kelas = "KEYWORD" if lex in KEYWORDS else "IDENTIFIER"
return Token(kelas, lex, line, col)
def _angka(self) -> Token:
line, col = self.line, self.column
start = self.pos
while not self._at_end() and self._peek().isdigit():
self._advance()
if self._peek() == "." and self._peek(1).isdigit():
self._advance() # '.'
while not self._at_end() and self._peek().isdigit():
self._advance()
return Token("NUMBER", self.src[start:self.pos], line, col)
def _string(self) -> Token:
line, col = self.line, self.column
self._advance() # tanda kutip pembuka
start = self.pos
while not self._at_end() and self._peek() != '"':
self._advance()
lex = '"' + self.src[start:self.pos] + '"'
if not self._at_end():
self._advance() # tanda kutip penutup
return Token("STRING", lex, line, col)
def _operator(self) -> Token:
line, col = self.line, self.column
dua = self._peek() + self._peek(1)
if dua in DUA_KARAKTER:
self._advance()
self._advance()
return Token("OPERATOR", dua, line, col)
ch = self._advance()
return Token("OPERATOR", ch, line, col)
def main() -> None:
source = (
'let n = 10$@3\n' # karakter ilegal $ dan @ pada baris 1
'let hasil = 0\n'
'print hasil + n?!\n' # karakter ilegal ? pada baris 3
)
lexer = Lexer(source)
tokens = lexer.tokens()
for e in lexer.errors():
print(f"error leksikal pada {e.line}:{e.column}: {e.message}")
print("--- token yang dipulihkan ---")
for t in tokens:
print(f"{t.line:>2}:{t.column:<3} {t.type:<10} {t.lexeme!r}")
if __name__ == "__main__":
main()Keluaran berikut diperoleh dengan menjalankan python3 lexer_errors.py. Baris pertama melaporkan deret $@ sebagai satu kesatuan pada 1:11, sedangkan baris kedua melaporkan ? sebagai karakter ilegal pada 3:16. Token 3 pada baris 1 dan token ! pada baris 3 tetap dihasilkan karena keduanya adalah karakter yang dikenali lexer setelah pemulihan.
error leksikal pada 1:11: karakter tak dikenal '$@'
error leksikal pada 3:16: karakter tak dikenal '?'
--- token yang dipulihkan ---
1:1 KEYWORD 'let'
1:5 IDENTIFIER 'n'
1:7 OPERATOR '='
1:9 NUMBER '10'
1:13 NUMBER '3'
2:1 KEYWORD 'let'
2:5 IDENTIFIER 'hasil'
2:11 OPERATOR '='
2:13 NUMBER '0'
3:1 KEYWORD 'print'
3:7 IDENTIFIER 'hasil'
3:13 OPERATOR '+'
3:15 IDENTIFIER 'n'
3:17 OPERATOR '!'
4:1 EOF ''Pada baris 1, token 10 dan 3 dihasilkan terpisah karena keduanya dipisahkan oleh deret ilegal $@. Bila panic mode hanya membuang satu karakter pada satu waktu, keluaran akan menampilkan dua pesan error terpisah untuk $ dan @. Strategi yang dipakai di sini menggabungkan keduanya menjadi satu pesan, sehingga pengguna melihat satu kesalahan ketik alih-alih dua.
Kesalahan umum#
Beberapa kesalahan sering muncul saat pemulihan error leksikal ditulis. Pertama, menghentikan lexer pada karakter ilegal pertama, sehingga kesalahan di baris berikutnya tidak dilaporkan. Kedua, membuang karakter ilegal tanpa mencatat pesan, sehingga pengguna tidak tahu bahwa ada masalah. Ketiga, mencatat pesan tanpa lokasi, sehingga pengguna harus menebak posisi karakter. Keempat, membuang karakter valid yang mengikuti deret ilegal, misalnya membuang ! pada ?!, padahal ! adalah operator yang sah di bahasa mini v1. Implementasi pada berkas lexer_errors.py menghindari keempat kesalahan ini dengan strategi panic mode yang berhenti pada karakter yang dikenali lexer.
Ringkasan#
- Error leksikal muncul ketika lexer menemui karakter di luar alphabet bahasa mini v1, dan pesan error yang baik menyebut kategori, karakter, dan lokasi.
- Pemulihan panic mode melompati deret karakter ilegal hingga karakter yang dikenali, sehingga satu kesalahan tidak menggagalkan seluruh proses dan kesalahan di baris berikutnya tetap dilaporkan.
- Setiap error leksikal dicatat sebagai
LexErrordengan fieldmessage,line, dancolumn, dan daftar ini diakses lewatLexer(source).errors()terpisah daritokens(). - Strategi pemulihan dipilih agar tidak membuang karakter valid yang mengikuti deret ilegal, misalnya operator
!pada?!tetap dihasilkan sebagai token.
Referensi#
- Compilers: Principles, Techniques, and Tools — Wikipedia — Bab “Lexical Analysis” pada buku ini membahas panic mode recovery dan strategi pelaporan error pada scanner.
- Crafting Interpreters — Bob Nystrom — Bab “Scanning” pada bahasa Lox menampilkan contoh penanganan error leksikal dengan pesan yang menyebut posisi sumber.
- Parsing — Wikipedia — Ringkasan proses parsing, termasuk strategi pemulihan error yang juga diterapkan pada tahap leksikal.