Análisis sintáctico

"El parser verifica que las palabras estén en el orden correcto — que no estés diciendo 'el come gato pescado' cuando querías decir 'el gato come pescado'."

Qué vas a aprender en este capítulo

El análisis sintáctico (parsing) toma la secuencia de tokens del lexer y verifica que tengan la estructura correcta según la gramática del lenguaje. Si la tienen, construye un AST (Árbol Sintáctico Abstracto) — la representación estructurada del programa que usarán las fases siguientes.


3.1 Gramática de PupusaScript

📐 Fundamento

Gramática BNF de PupusaScript (fragmento):

programa    → PROGRAMA IDENTIFICADOR bloque_vars? INICIO sentencias FIN

bloque_vars → VARIABLES DOSPUNTOS declaracion+

declaracion → IDENTIFICADOR DOSPUNTOS tipo
tipo        → ENTERO | DECIMAL

sentencias  → sentencia*

sentencia   → asignacion
            | si_stmt
            | mientras_stmt
            | imprimir_stmt
            | leer_stmt

asignacion  → IDENTIFICADOR ASIGNACION expr

si_stmt     → SI expr ENTONCES sentencias
              (SINO sentencias)?
              FIN_SI

mientras_stmt → MIENTRAS expr HACER sentencias FIN_MIENTRAS

imprimir_stmt → IMPRIMIR LPAREN expr RPAREN
leer_stmt     → IDENTIFICADOR ASIGNACION LEER LPAREN STRING RPAREN

expr        → expr (MAS | MENOS) term
            | term

term        → term (MUL | DIV) factor
            | factor

factor      → LPAREN expr RPAREN
            | NUM_DECIMAL
            | NUM_ENTERO
            | STRING
            | IDENTIFICADOR
            | (MAS | MENOS) factor      ← unario

Ambigüedad: Una gramática es ambigua si la misma cadena tiene más de un árbol de derivación. La gramática de expr arriba no es ambigua porque la precedencia está codificada en la jerarquía (term tiene mayor precedencia que expr).


3.2 Parser descendente recursivo

💡 Intuición

El parser descendente recursivo es el tipo más fácil de implementar manualmente: cada no terminal de la gramática se convierte en una función Python. La función "consume" los tokens que corresponden a esa regla y devuelve el nodo del AST.

Es como leer una oración en voz alta siguiendo las reglas gramaticales: cuando ves "si", sabés que viene una condición, luego "entonces", luego el cuerpo, etc.

📐 Fundamento

Nodos del AST:

from dataclasses import dataclass, field
from typing import Optional, List, Union

@dataclass
class Programa:
    nombre: str
    declaraciones: List['Declaracion']
    cuerpo: List['Sentencia']

@dataclass
class Declaracion:
    nombre: str
    tipo: str  # 'entero' | 'decimal'

@dataclass
class Asignacion:
    destino: str
    valor: 'Expr'

@dataclass
class SiStmt:
    condicion: 'Expr'
    entonces: List['Sentencia']
    sino: Optional[List['Sentencia']] = None

@dataclass
class ImprimirStmt:
    expresion: 'Expr'

@dataclass
class BinOp:
    izq: 'Expr'
    op: str
    der: 'Expr'

@dataclass
class Numero:
    valor: Union[int, float]

@dataclass
class Identificador:
    nombre: str

@dataclass
class StringLiteral:
    valor: str

Expr = Union[BinOp, Numero, Identificador, StringLiteral]
Sentencia = Union[Asignacion, SiStmt, ImprimirStmt]

Parser descendente recursivo:

class Parser:
    def __init__(self, tokens):
        self.tokens = tokens
        self.pos = 0
    
    def peek(self):
        """Ver el token actual sin consumirlo."""
        if self.pos < len(self.tokens):
            return self.tokens[self.pos]
        return None
    
    def consumir(self, tipo_esperado):
        """Consumir el token actual si es del tipo esperado."""
        tok = self.peek()
        if tok is None:
            raise SyntaxError(f"Se esperaba {tipo_esperado} pero se llegó al final")
        if tok.type != tipo_esperado:
            raise SyntaxError(
                f"Línea {tok.lineno}: se esperaba {tipo_esperado}, "
                f"se encontró {tok.type} ('{tok.value}')"
            )
        self.pos += 1
        return tok
    
    def parse_programa(self) -> Programa:
        self.consumir('PROGRAMA')
        nombre = self.consumir('IDENTIFICADOR').value
        
        declaraciones = []
        if self.peek() and self.peek().type == 'VARIABLES':
            declaraciones = self.parse_bloque_vars()
        
        self.consumir('INICIO')
        cuerpo = self.parse_sentencias()
        self.consumir('FIN')
        
        return Programa(nombre=nombre, declaraciones=declaraciones, cuerpo=cuerpo)
    
    def parse_bloque_vars(self) -> List[Declaracion]:
        self.consumir('VARIABLES')
        self.consumir('DOSPUNTOS')
        declaraciones = []
        while self.peek() and self.peek().type == 'IDENTIFICADOR':
            nombre = self.consumir('IDENTIFICADOR').value
            self.consumir('DOSPUNTOS')
            tipo_tok = self.peek()
            if tipo_tok.type in ('ENTERO', 'DECIMAL'):
                self.pos += 1
                declaraciones.append(Declaracion(nombre=nombre, tipo=tipo_tok.value))
            else:
                raise SyntaxError(f"Tipo inválido: {tipo_tok.value}")
        return declaraciones
    
    def parse_sentencias(self) -> List[Sentencia]:
        stmts = []
        while self.peek() and self.peek().type not in ('FIN', 'SINO', 'FIN_SI', 'FIN_MIENTRAS'):
            stmts.append(self.parse_sentencia())
        return stmts
    
    def parse_sentencia(self) -> Sentencia:
        tok = self.peek()
        if tok.type == 'SI':
            return self.parse_si()
        elif tok.type == 'IMPRIMIR':
            return self.parse_imprimir()
        elif tok.type == 'IDENTIFICADOR':
            return self.parse_asignacion()
        else:
            raise SyntaxError(f"Línea {tok.lineno}: sentencia inesperada '{tok.value}'")
    
    def parse_asignacion(self) -> Asignacion:
        nombre = self.consumir('IDENTIFICADOR').value
        self.consumir('ASIGNACION')
        valor = self.parse_expr()
        return Asignacion(destino=nombre, valor=valor)
    
    def parse_si(self) -> SiStmt:
        self.consumir('SI')
        cond = self.parse_expr()
        self.consumir('ENTONCES')
        entonces = self.parse_sentencias()
        sino = None
        if self.peek() and self.peek().type == 'SINO':
            self.consumir('SINO')
            sino = self.parse_sentencias()
        self.consumir('FIN_SI')
        return SiStmt(condicion=cond, entonces=entonces, sino=sino)
    
    def parse_imprimir(self) -> ImprimirStmt:
        self.consumir('IMPRIMIR')
        self.consumir('LPAREN')
        expr = self.parse_expr()
        self.consumir('RPAREN')
        return ImprimirStmt(expresion=expr)
    
    def parse_expr(self) -> Expr:
        """expr → term (('+' | '-') term)*"""
        izq = self.parse_term()
        while self.peek() and self.peek().type in ('MAS', 'MENOS'):
            op = self.pos; self.pos += 1
            der = self.parse_term()
            izq = BinOp(izq=izq, op=self.tokens[op].value, der=der)
        return izq
    
    def parse_term(self) -> Expr:
        """term → factor (('*' | '/') factor)*"""
        izq = self.parse_factor()
        while self.peek() and self.peek().type in ('MUL', 'DIV'):
            op = self.pos; self.pos += 1
            der = self.parse_factor()
            izq = BinOp(izq=izq, op=self.tokens[op].value, der=der)
        return izq
    
    def parse_factor(self) -> Expr:
        tok = self.peek()
        if tok.type == 'NUM_DECIMAL':
            self.pos += 1
            return Numero(valor=tok.value)
        elif tok.type == 'NUM_ENTERO':
            self.pos += 1
            return Numero(valor=tok.value)
        elif tok.type == 'STRING':
            self.pos += 1
            return StringLiteral(valor=tok.value)
        elif tok.type == 'IDENTIFICADOR':
            self.pos += 1
            return Identificador(nombre=tok.value)
        elif tok.type == 'LPAREN':
            self.consumir('LPAREN')
            e = self.parse_expr()
            self.consumir('RPAREN')
            return e
        else:
            raise SyntaxError(f"Línea {tok.lineno}: factor inesperado '{tok.value}'")

3.3 Parsers LL(1) y LR — conceptual

📐 Fundamento

LL(1) — Top-down con 1 token de lookahead:

El parser descendente recursivo es un LL(1) manual. La "L" significa que lee de izquierda a derecha, la segunda "L" que hace derivaciones izquierdas, y el "(1)" que mira 1 token adelante para decidir qué regla aplicar.

Limitación: LL(1) no puede manejar recursión izquierda directa:

expr → expr '+' term   ← recursión izquierda: el parser llama a expr() antes de consumir
     | term             ← un token de lookahead no alcanza para decidir

Solución: eliminar recursión izquierda:

expr  → term expr'
expr' → '+' term expr'
      | '-' term expr'
      | ε

Esta transformación es equivalente pero sin recursión izquierda.

LR(1) — Bottom-up con 1 token de lookahead:

Los parsers LR construyen el árbol de abajo hacia arriba: acumulan tokens en una pila (shift) y cuando reconocen un patrón, lo reducen a un no terminal (reduce).

  • Más poderosos que LL(1) — manejan gramáticas más amplias.
  • Más difíciles de implementar a mano → se usan herramientas como PLY/yacc, Bison.
  • Usados en la mayoría de compiladores de producción.

PLY para el parser:

import ply.yacc as yacc

def p_expr_binop(p):
    '''expr : expr MAS term
            | expr MENOS term'''
    p[0] = BinOp(izq=p[1], op=p[2], der=p[3])

def p_expr_term(p):
    '''expr : term'''
    p[0] = p[1]

def p_term_mul(p):
    '''term : term MUL factor
            | term DIV factor'''
    p[0] = BinOp(izq=p[1], op=p[2], der=p[3])

def p_factor_numero(p):
    '''factor : NUM_DECIMAL
              | NUM_ENTERO'''
    p[0] = Numero(valor=p[1])

def p_error(p):
    if p:
        print(f"Error sintáctico en '{p.value}', línea {p.lineno}")
    else:
        print("Error sintáctico: fin inesperado del archivo")

parser = yacc.yacc()
ast = parser.parse("precio * 1.13", lexer=lexer)

3.4 Ejercicios

✏️ Ejercicio 3.1 — Árbol de derivación

Para la expresión 2 + 3 * 4 - 1, dibujá el árbol de derivación usando la gramática de PupusaScript (expr → expr op term → ...). ¿Cuál es el orden de evaluación?

✏️ Ejercicio 3.2 — Extender la gramática

Extendé la gramática de PupusaScript para soportar llamadas a funciones:

total <- calcular_total(mesa, descuento)

Escribí las reglas BNF adicionales y modificá parse_factor() para manejar el caso IDENTIFICADOR LPAREN args RPAREN.


3.5 Para profundizar

3.X Errores comunes

⚠️ Trampa común

Recursión izquierda en un parser descendente recursivo. Una regla como expr → expr '+' term | term parece natural, pero hace que un parser top-down entre en bucle infinito: para reconocer expr llama a expr. Los parsers descendentes recursivos no soportan recursión izquierda directa.

Tip: transformá a recursión derecha o usá iteración: expr → term ('+' term)*. Si necesitás asociatividad izquierda real, generá el AST plegando con un for desde el resultado, no con la gramática.

⚠️ Trampa común

Ambigüedad de if-else colgante (dangling else). La gramática stmt → if expr stmt | if expr stmt else stmt permite dos parseos para if A if B X else Y: el else puede pertenecer al if interno o al externo. Si no lo desambiguás, distintos parsers dan resultados distintos.

Tip: la convención (C, Java, Python no tiene este problema, ALGOL lo resolvió) es el else se asocia con el if más cercano sin else. En la gramática se expresa con un terminal de "matched/unmatched" o con una directiva de precedencia en el generador (yacc: %prec).


Definiciones nuevas: parser, análisis sintáctico, gramática BNF, AST (Abstract Syntax Tree), parser descendente recursivo, recursión izquierda, LL(1), LR(1), shift-reduce, lookahead, ambigüedad, precedencia de operadores, regla BNF.