পাঠ ২৭ · ৫৮-এর মধ্যে · মডিউল ৫
Home / Courses / Concepts of Programming Languages & Compiler Design / এরর রিকভারি

সিনট্যাক্স এরর রিকভারি

Syntax error recovery
৭ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • কেন একটি প্রোডাকশন-কোয়ালিটি পার্সারকে প্রথম সিনট্যাক্স এরর পেয়েই থেমে যাওয়া উচিত নয়
  • প্যানিক মোড রিকভারি — সিঙ্ক্রোনাইজিং সেট ব্যবহার করে কীভাবে পার্সিং আবার শুরু করা হয়
  • ফ্রেজ-লেভেল রিকভারির সংক্ষিপ্ত ধারণা এবং প্যানিক মোডের সাথে তুলনা
  • L22-এর পার্সারে বাস্তব প্যানিক-মোড রিকভারি যোগ করে একই পাসে দুটো এরর রিপোর্ট করা

১ · সমস্যা — একটি এরর পেয়েই থেমে যাওয়া কেন খারাপ অভিজ্ঞতা

কল্পনা করুন একটি কম্পাইলার যা একটি ফাইলে ১০টি টাইপো থাকলে শুধু প্রথমটি রিপোর্ট করে থেমে যায়। প্রোগ্রামার সেটি ঠিক করে আবার কম্পাইল করে — আবার থেমে যায়, এবার দ্বিতীয় এররে। দশবার এই চক্র চালাতে হয়। বাস্তব কম্পাইলার (GCC, Python-এর নিজস্ব পার্সার, ইত্যাদি) এভাবে কাজ করে না — তারা এরর রিকভারিError Recoveryএকটি সিনট্যাক্স এরর পাওয়ার পর পার্সারকে পুরোপুরি থামিয়ে না দিয়ে, কোনোভাবে "সুস্থ" একটি অবস্থায় ফিরিয়ে এনে বাকি ইনপুট পার্সিং চালিয়ে যাওয়ার কৌশল — যাতে একই পাসে সম্ভাব্য একাধিক এরর একসাথে রিপোর্ট করা যায়। কৌশল ব্যবহার করে একই পাসে যতগুলো সম্ভব এরর একসাথে দেখিয়ে দেয়।

লক্ষ্য করুন — এরর রিকভারির উদ্দেশ্য প্রোগ্রামকে "সঠিক" বানানো নয় (ভাঙা প্রোগ্রাম ভাঙাই থাকে), বরং পার্সারকে ক্র্যাশ করা থেকে বিরত রাখা এবং যতটা সম্ভব বেশি প্রকৃত এরর খুঁজে বের করা — একই সাথে খুব বেশি "ভুয়া" (spurious) এরর তৈরি না করা, যা একটি সৎ ট্রেড-অফ।

২ · প্যানিক মোড রিকভারি

প্যানিক মোড রিকভারিPanic Mode Recoveryসবচেয়ে সহজ ও সবচেয়ে বহুল-ব্যবহৃত এরর রিকভারি কৌশল — এরর পাওয়ার পর একটি পূর্বনির্ধারিত "সিঙ্ক্রোনাইজিং সেট"-এর কোনো টোকেন না পাওয়া পর্যন্ত ইনপুট টোকেন একে একে বাদ দিতে থাকা, তারপর সেখান থেকে স্বাভাবিক পার্সিং আবার শুরু করা। কাজ করে এভাবে —

  1. পার্সার একটি অপ্রত্যাশিত টোকেন পায় → এরর রিপোর্ট করে।
  2. একটি পূর্বনির্ধারিত সিঙ্ক্রোনাইজিং সেটএমন টোকেনের একটি সেট যা গ্রামারের মধ্যে সাধারণত একটি নিরাপদ "রিস্টার্ট পয়েন্ট" নির্দেশ করে — যেমন কমা, সেমিকোলন, অথবা বন্ধ ব্র্যাকেট। (যেমন কমা, সেমিকোলন) থেকে কোনো টোকেন না পাওয়া পর্যন্ত ইনপুট টোকেন একে একে বাদ দেওয়া হয়।
  3. সিঙ্ক্রোনাইজিং টোকেন পাওয়া গেলে, সেখান থেকে স্বাভাবিক পার্সিং আবার শুরু হয় — যেন কিছুই হয়নি।

এই কৌশল বাস্তবায়ন করা সহজ, এবং পার্সারকে সরাসরি ক্র্যাশ হওয়া থেকে নির্ভরযোগ্যভাবে রক্ষা করে, বেশিরভাগ প্রকৃত এরর খুঁজে বের করতেও পারে — তবে সততার সাথে বলতে হয়, মাঝেমধ্যে এটি কিছু "ভুয়া" ফলো-অন এরর তৈরি করতে পারে (একটি বড় অংশ স্কিপ করার ফলে পরের কিছু বৈধ কোডও ভুলভাবে এরর হিসেবে ধরা পড়তে পারে) — এটি একটি বাস্তব সীমাবদ্ধতা।

এক্সপ্রেশন পার্স করার চেষ্টা অপ্রত্যাশিত টোকেন → এরর রিপোর্ট সিঙ্ক্রোনাইজিং টোকেন না মেলা পর্যন্ত স্কিপ সিঙ্ক্রোনাইজিং টোকেন পাওয়া গেছে পরের এক্সপ্রেশন থেকে পার্সিং পুনরায় শুরু
একটি এরর পার্সারকে থামায় না — শুধু বর্তমান এক্সপ্রেশনটি স্কিপ করে দেয়, পরেরটির পার্সিং স্বাভাবিকভাবেই চলতে থাকে।

৩ · ফ্রেজ-লেভেল রিকভারি — সংক্ষিপ্ত পরিচিতি

ফ্রেজ-লেভেল রিকভারিPhrase-Level Recoveryএরর পয়েন্টেই স্থানীয় সংশোধন (একটি অনুপস্থিত টোকেন যোগ করা, বা একটি অতিরিক্ত টোকেন বাদ দেওয়া) করার চেষ্টা করে পার্সিং আরও নিখুঁতভাবে চালিয়ে যাওয়ার কৌশল — প্যানিক মোডের চেয়ে জটিল কিন্তু আরও লক্ষ্যভেদী এরর মেসেজ দিতে পারে। প্যানিক মোডের বিকল্প হিসেবে ব্যবহৃত হয় — যেমন যদি একটি বাক্যের শেষে সেমিকোলন অনুপস্থিত থাকে, পার্সার একটি "ভার্চুয়াল" সেমিকোলন insert করে ধরে নিয়ে পার্সিং চালিয়ে যেতে পারে। এটি বাস্তবায়ন করা প্যানিক মোডের চেয়ে বেশি জটিল (প্রতিটি সম্ভাব্য এরর পয়েন্টের জন্য কী সংশোধন করা উচিত তা আলাদাভাবে ঠিক করতে হয়), তাই এই পাঠের কোড সেল সহজ ও নির্ভরযোগ্য প্যানিক মোড বাস্তবায়ন করে দেখাবে।

M5-এর সাথে সম্পর্ক

এই পাঠ M5/L22-এর রিকার্সিভ ডিসেন্ট পার্সারকে সরাসরি সম্প্রসারিত করে — গ্রামার (expr, term, factor) ও পার্সিং ফাংশনগুলো অভিন্ন থাকে, শুধু একটি নতুন synchronize() ফাংশন এবং একটি try/except-ভিত্তিক এরর-ধরার লুপ যোগ হয়েছে। এখান থেকেই M6-এর সিমান্টিক অ্যানালাইসিস শুরু হবে — পরের পাঠ (L28) থেকে ধরে নেওয়া হবে ইনপুট ইতিমধ্যে সিনট্যাক্টিকালি বৈধ, এবং প্রশ্ন হবে এর "অর্থ" বৈধ কিনা।

৪ · প্যানিক-মোড রিকভারিসহ পার্সার — দুটো এরর, একই পাসে

নিচের কোড সেলে একটি কমা-বিভক্ত এক্সপ্রেশন তালিকা (যেমন একটি ফাংশন কলের আর্গুমেন্ট তালিকার মতো) পার্স করা হচ্ছে। প্রতিটি এক্সপ্রেশন আলাদাভাবে parse_expr দিয়ে পার্স করা হয়; কোনো এক্সপ্রেশনে এরর পাওয়া গেলে, synchronize() পরবর্তী কমা (COMMA) না পাওয়া পর্যন্ত টোকেন স্কিপ করে, তারপর পরের এক্সপ্রেশন থেকে পার্সিং চালিয়ে যায়। ইনপুটে ইচ্ছাকৃতভাবে দুটো ভাঙা সাব-এক্সপ্রেশন রাখা হয়েছে।

Python
# L22-এর রিকার্সিভ ডিসেন্ট গ্রামার + প্যানিক-মোড এরর রিকভারি

def tokenize(s):
    tokens = []
    names = {'+': 'PLUS', '-': 'MINUS', '*': 'STAR', '/': 'SLASH',
             '(': 'LPAREN', ')': 'RPAREN', ',': 'COMMA'}
    i = 0
    while i < len(s):
        ch = s[i]
        if ch.isspace():
            i += 1
        elif ch.isdigit():
            start = i
            while i < len(s) and s[i].isdigit():
                i += 1
            tokens.append(("NUMBER", s[start:i]))
        elif ch in names:
            tokens.append((names[ch], ch))
            i += 1
        else:
            raise ValueError(f"অজানা ক্যারেক্টার: {ch!r}")
    tokens.append(("EOF", ""))
    return tokens


class ParseError(Exception):
    """সিনট্যাক্স এরর -- এরর মেসেজ এবং এরর যেখানে ঘটেছে সেই পজিশন ধরে রাখে।"""
    def __init__(self, message, pos):
        super().__init__(message)
        self.pos = pos


# --- expr ::= term expr' | expr' ::= (+|-) term expr' | eps ---
# --- term ::= factor term' | term' ::= (*|/) factor term' | eps ---
# --- factor ::= NUMBER | ( expr ) ---  (বাম-রিকার্শন-মুক্ত, L22 অনুযায়ী)

def parse_factor(tokens, pos):
    kind, val = tokens[pos]
    if kind == "NUMBER":
        return ("num", int(val)), pos + 1
    if kind == "LPAREN":
        node, pos = parse_expr(tokens, pos + 1)
        kind2, val2 = tokens[pos]
        if kind2 != "RPAREN":
            raise ParseError(f"')' প্রত্যাশিত, পাওয়া গেছে {val2!r}", pos)
        return node, pos + 1
    raise ParseError(f"সংখ্যা বা '(' প্রত্যাশিত, পাওয়া গেছে {val!r}", pos)

def parse_term_prime(tokens, pos, left):
    kind, val = tokens[pos]
    if kind in ("STAR", "SLASH"):
        right, pos = parse_factor(tokens, pos + 1)
        return parse_term_prime(tokens, pos, ("binop", val, left, right))
    return left, pos

def parse_term(tokens, pos):
    left, pos = parse_factor(tokens, pos)
    return parse_term_prime(tokens, pos, left)

def parse_expr_prime(tokens, pos, left):
    kind, val = tokens[pos]
    if kind in ("PLUS", "MINUS"):
        right, pos = parse_term(tokens, pos + 1)
        return parse_expr_prime(tokens, pos, ("binop", val, left, right))
    return left, pos

def parse_expr(tokens, pos):
    left, pos = parse_term(tokens, pos)
    return parse_expr_prime(tokens, pos, left)

def evaluate(node):
    if node[0] == "num":
        return node[1]
    _, op, left, right = node
    lv, rv = evaluate(left), evaluate(right)
    return {"+": lv + rv, "-": lv - rv, "*": lv * rv, "/": lv / rv}[op]


SYNC_SET = {"COMMA", "EOF"}

def synchronize(tokens, pos):
    """সিঙ্ক্রোনাইজিং সেটের একটি টোকেন না মেলা পর্যন্ত ইনপুট বাদ দিতে থাকে।"""
    while tokens[pos][0] not in SYNC_SET:
        pos += 1
    return pos

def parse_expression_list(tokens):
    """কমা-বিভক্ত এক্সপ্রেশন তালিকা পার্স করে -- প্রতিটি এক্সপ্রেশনের এরর আলাদাভাবে রিকভার হয়।"""
    results, errors = [], []
    pos = 0
    while True:
        try:
            node, pos = parse_expr(tokens, pos)
            results.append(node)
        except ParseError as e:
            errors.append((str(e), e.pos))
            results.append(None)
            pos = synchronize(tokens, e.pos)
        kind, _ = tokens[pos]
        if kind == "COMMA":
            pos += 1
            continue
        break  # EOF
    return results, errors


# ইচ্ছাকৃতভাবে দুটো ভাঙা সাব-এক্সপ্রেশনসহ ইনপুট -- মাঝে বৈধ এক্সপ্রেশনও আছে
source = "2 + 3, * 5, 4 + ), 7 * 2"
print(f"সোর্স: {source!r}\n")

tokens = tokenize(source)
results, errors = parse_expression_list(tokens)

print(f"মোট এক্সপ্রেশন: {len(results)}, সফল: {sum(1 for r in results if r)}, এরর: {len(errors)}\n")

print("প্রতিটি এক্সপ্রেশনের ফলাফল:")
for i, node in enumerate(results, start=1):
    if node is not None:
        print(f"  এক্সপ্রেশন {i}: মান = {evaluate(node)}")
    else:
        print(f"  এক্সপ্রেশন {i}: (এরর -- স্কিপ করা হয়েছে)")

print("\nরিপোর্ট করা এরর:")
for msg, pos in errors:
    print(f"  পজিশন {pos}-এ: {msg}")

    
লক্ষ্য করুন — প্রথম এরর ("* 5"-এ, factor একটি সংখ্যা বা '(' আশা করেছিল কিন্তু '*' পেয়েছে) পাওয়ার পরও পার্সার থামেনি। synchronize() পরের কমা পর্যন্ত স্কিপ করেছে, এবং পার্সিং তৃতীয় এক্সপ্রেশন ("4 + )") থেকে চালিয়ে গেছে — যেখানে দ্বিতীয় এররটি ধরা পড়েছে। এক্সপ্রেশন ১ (মান ৫) এবং এক্সপ্রেশন ৪ (মান ১৪) — দুটো বৈধ এক্সপ্রেশনই সঠিকভাবে এভালুয়েট হয়েছে, দুটো ভাঙা এক্সপ্রেশনের কারণে পুরো পার্সিং ক্র্যাশ করেনি।
মূল কথা · Key takeaway

এরর রিকভারি একটি পার্সারকে "এক এরর পেলেই থেমে যাওয়া" থেকে "যতটা সম্ভব বেশি প্রকৃত এরর একই পাসে দেখানো"-তে রূপান্তর করে। প্যানিক মোড — একটি সিঙ্ক্রোনাইজিং সেট পর্যন্ত টোকেন স্কিপ করে আবার শুরু করা — সহজ, নির্ভরযোগ্য, এবং বাস্তব কম্পাইলারগুলোর একটি ভিত্তি কৌশল, যদিও মাঝেমধ্যে সামান্য ভুয়া ফলো-অন এরর তৈরি করতে পারে।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।

প্র ০১ একটি পার্সার যদি এরর রিকভারি ছাড়াই লেখা হতো (প্রথম এরর পেলেই সম্পূর্ণভাবে বন্ধ হয়ে যেত), তাহলে ব্যবহারিকভাবে কী সমস্যা হতো?

একটি বড় ফাইলে একাধিক টাইপো থাকলে প্রোগ্রামারকে প্রতিবার একটিমাত্র এরর দেখে, সেটি ঠিক করে, আবার পুরো ফাইল কম্পাইল করে পরের এররটি খুঁজে বের করতে হতো — একটি ধীর, বিরক্তিকর ফিক্স-রিকম্পাইল চক্র। বাস্তব কম্পাইলার এই সমস্যা এড়াতেই এরর রিকভারি ব্যবহার করে, যাতে একবারেই যতটা সম্ভব বেশি প্রকৃত এরর দেখানো যায়।

প্র ০২ উপরের কোড সেলে সিঙ্ক্রোনাইজিং সেট হিসেবে COMMA ব্যবহার করা হয়েছে। যদি সিঙ্ক্রোনাইজিং সেট খালি (empty) হতো, তাহলে কী হতো?

synchronize() ফাংশনটি while tokens[pos][0] not in SYNC_SET লুপে EOF পর্যন্ত কখনো থামত না (যেহেতু EOF-ও সেটে না থাকলে কখনো মিলবে না) — ফলে ইনপুট শেষ না হওয়া পর্যন্ত পুরোটাই স্কিপ হয়ে যেত এবং বাকি সব এক্সপ্রেশন হারিয়ে যেত। এই কারণেই EOF-কে সবসময় সিঙ্ক্রোনাইজিং সেটে অন্তর্ভুক্ত করা জরুরি — এটি একটি "নিরাপত্তা জাল" হিসেবে কাজ করে, যাতে সিঙ্ক্রোনাইজেশন সবসময় কোথাও না কোথাও থামে।

প্র ০৩ প্যানিক মোড রিকভারি ও ফ্রেজ-লেভেল রিকভারির মধ্যে মূল পার্থক্য কী, এবং কেন এই পাঠের কোড সেল প্যানিক মোড বেছে নিয়েছে?

প্যানিক মোড এররের পুরো "সংক্রামিত" অংশ (এখানে, একটি সম্পূর্ণ ভাঙা এক্সপ্রেশন) বাদ দিয়ে পরের নিরাপদ পয়েন্ট থেকে আবার শুরু করে — সহজ এবং নির্ভরযোগ্য, কিন্তু কম নিখুঁত। ফ্রেজ-লেভেল রিকভারি এরর পয়েন্টেই একটি স্থানীয় সংশোধন (যেমন একটি অনুপস্থিত টোকেন insert করে ধরে নেওয়া) করে চালিয়ে যাওয়ার চেষ্টা করে — বেশি নিখুঁত এরর মেসেজ দেয় কিন্তু প্রতিটি সম্ভাব্য এরর পরিস্থিতির জন্য আলাদা সংশোধন-নিয়ম লিখতে হয়, তাই জটিলতা বেশি। এই পাঠ শেখানোর উদ্দেশ্যে প্যানিক মোড বেছে নিয়েছে কারণ এটি অল্প কোডে নির্ভরযোগ্যভাবে কাজ করে এবং মূল ধারণা (এরর পেয়েও থেমে না যাওয়া) স্পষ্টভাবে দেখায়।

অনুশীলন

  1. চিন্তা করুন: উপরের কোড সেলের SYNC_SET-এ যদি শুধু {"EOF"} রাখা হতো (COMMA বাদ দিয়ে), তাহলে "2 + 3, * 5, 4 + ), 7 * 2" ইনপুটে কতগুলো এক্সপ্রেশন সফলভাবে পার্স হতো বলে আপনার মনে হয়?

    মাত্র একটি — প্রথম এররের ("* 5") পর synchronize() সরাসরি EOF পর্যন্ত সব টোকেন স্কিপ করে ফেলত (কারণ COMMA আর সিঙ্ক্রোনাইজিং সেটে নেই), ফলে "4 + )", ", 7 * 2" সহ বাকি সবকিছু হারিয়ে যেত এবং শুধু প্রথম এক্সপ্রেশনটি (মান ৫) রিপোর্ট হতো, সাথে একটি মাত্র এরর। এটি দেখায় সিঙ্ক্রোনাইজিং সেট সঠিকভাবে বাছাই করা কতটা গুরুত্বপূর্ণ — খুব ছোট সেট মানে খুব বেশি ইনপুট হারিয়ে যাওয়া।

  2. পরীক্ষা করুন: উপরের কোড সেলে source-এর মান "* 1, 2 + 2, ( 3" করে Run চেপে দেখুন কয়টি এরর ও কয়টি সফল এক্সপ্রেশন রিপোর্ট হয়।

    এক্সপ্রেশন ১ ("* 1") এরর দেয় (factor-এ সরাসরি '*' পাওয়া গেছে)। এক্সপ্রেশন ২ ("2 + 2") সফলভাবে পার্স হয়ে মান ৪ দেয়। এক্সপ্রেশন ৩ ("( 3") এরর দেয় — parse_factor LPAREN দেখে ভেতরের এক্সপ্রেশন পার্স করে, কিন্তু বন্ধনী শেষ না হয়েই ইনপুট EOF-এ পৌঁছে যায়, তাই ')' প্রত্যাশিত জায়গায় EOF পাওয়া যায় — একটি এরর। মোট ফলাফল: ৩টি এক্সপ্রেশনের মধ্যে ১টি সফল, ২টি এরর।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

আগের পাঠ
LALR পার্সিং ও পার্সার জেনারেটর