Análisis léxico
"El lexer es el lector del compilador — transforma texto crudo en palabras significativas, igual que nuestro cerebro convierte formas en letras."
Qué vas a aprender en este capítulo
El análisis léxico es la primera fase del compilador: toma el código fuente como una cadena de caracteres y lo convierte en una secuencia de tokens — las unidades mínimas con significado. Este capítulo implementa el lexer de PupusaScript.
2.1 ¿Qué es un token?
💡 Intuición
Cuando lees "El gato come pescado", tu cerebro no procesa letra por letra — agrupa en palabras y cada palabra tiene un tipo: artículo, sustantivo, verbo. Un lexer hace lo mismo con código.
total <- 8.50 * 1.13 → [IDENT:"total"] [ASIG:"<-"] [NUM:8.50] [OP:"*"] [NUM:1.13]
📐 Fundamento
Un token tiene:
- Tipo: categoría (IDENTIFICADOR, NUMERO, PALABRA_CLAVE, OPERADOR...)
- Valor (lexema): el texto original (
total,8.50,<-) - Posición: línea y columna (para mensajes de error útiles)
Lo que el lexer ignora:
- Espacios en blanco (generalmente)
- Comentarios
- Líneas nuevas (en muchos lenguajes)
Errores léxicos comunes:
- Carácter no permitido en el alfabeto:
@,$en PupusaScript - String sin cerrar:
"hola - Número malformado:
3.14.15
Herramientas para construir lexers:
| Herramienta | Lenguaje | Uso |
|---|---|---|
| PLY (Python Lex-Yacc) | Python | Académico, fácil de aprender |
| ANTLR | Java/Python/otros | Producción, gramáticas complejas |
| Flex | C | Clásico, genera C muy rápido |
| re (stdlib) | Python | Suficiente para lexers simples |
2.2 Implementar el lexer de PupusaScript
📐 Fundamento
Lexer completo con PLY:
import ply.lex as lex
# Palabras reservadas (no pueden ser identificadores)
palabras_reservadas = {
'si': 'SI', 'sino': 'SINO', 'fin_si': 'FIN_SI',
'mientras': 'MIENTRAS', 'fin_mientras': 'FIN_MIENTRAS',
'inicio': 'INICIO', 'fin': 'FIN',
'variables': 'VARIABLES',
'entero': 'ENTERO', 'decimal': 'DECIMAL',
'imprimir': 'IMPRIMIR', 'leer': 'LEER',
'programa': 'PROGRAMA',
'entonces': 'ENTONCES', 'hacer': 'HACER',
'verdadero': 'VERDADERO', 'falso': 'FALSO',
}
# Lista de tokens que el parser necesita conocer
tokens = list(set(palabras_reservadas.values())) + [
'IDENTIFICADOR', 'NUM_DECIMAL', 'NUM_ENTERO', 'STRING',
'ASIGNACION', # <-
'MAS', 'MENOS', 'MUL', 'DIV',
'EQ', 'NEQ', 'LT', 'GT', 'LTE', 'GTE', # ==, !=, <, >, <=, >=
'LPAREN', 'RPAREN',
'COMA', 'DOSPUNTOS',
]
# Tokens simples (sin función — basta con la regex)
t_ASIGNACION = r'<-'
t_MAS = r'\+'
t_MENOS = r'-'
t_MUL = r'\*'
t_DIV = r'/'
t_EQ = r'=='
t_NEQ = r'!='
t_LTE = r'<='
t_GTE = r'>='
t_LT = r'<'
t_GT = r'>'
t_LPAREN = r'\('
t_RPAREN = r'\)'
t_COMA = r','
t_DOSPUNTOS = r':'
# Tokens que requieren lógica adicional
def t_NUM_DECIMAL(t):
r'\d+\.\d+' # primero, antes de NUM_ENTERO (más específico)
t.value = float(t.value)
return t
def t_NUM_ENTERO(t):
r'\d+'
t.value = int(t.value)
return t
def t_STRING(t):
r'"[^"]*"'
t.value = t.value[1:-1] # remover comillas
return t
def t_IDENTIFICADOR(t):
r'[a-záéíóúüA-ZÁÉÍÓÚÜ_][a-záéíóúüA-ZÁÉÍÓÚÜ_0-9]*'
t.type = palabras_reservadas.get(t.value, 'IDENTIFICADOR')
return t
# Caracteres ignorados
t_ignore = ' \t'
def t_COMENTARIO(t):
r'\#[^\n]*'
pass # ignorar comentarios
def t_newline(t):
r'\n+'
t.lexer.lineno += len(t.value) # rastrear número de línea
def t_error(t):
print(f"Error léxico: carácter inesperado '{t.value[0]}' en línea {t.lexer.lineno}")
t.lexer.skip(1) # saltar el carácter inválido y continuar
# Construir el lexer
lexer = lex.lex()
# Probar
codigo = '''
programa calcular_iva
variables:
precio: decimal
iva: decimal
inicio
precio <- leer("Ingrese el precio: ")
iva <- precio * 0.13
imprimir("IVA: " + iva)
fin
'''
lexer.input(codigo)
for tok in lexer:
print(f" {tok.type:15} | {str(tok.value):20} | línea {tok.lineno}")
Salida:
PROGRAMA | programa | línea 2
IDENTIFICADOR | calcular_iva | línea 2
VARIABLES | variables | línea 4
IDENTIFICADOR | precio | línea 5
DOSPUNTOS | : | línea 5
DECIMAL | decimal | línea 5
...
2.3 De regex a DFA: la teoría detrás del lexer
📐 Fundamento
Las herramientas como PLY automatizan este proceso, pero es útil entender qué hacen internamente:
Paso 1: Regex → NFA (Construcción de Thompson)
Para cada regex, se construye un NFA usando reglas composicionales:
Para 'a': →(q0) ─a→ ((q1))
Para 'a|b': →(q0) ─ε→ (q1) ─a→ ((q3))
└─ε→ (q2) ─b→ ((q3))
Para 'a*': →(q0) ─ε→ (q1) ─a→ (q2) ─ε→ ((q3))
└───────────ε────────────────┘
└───────────────ε────────────┘
Paso 2: NFA → DFA (Construcción de subconjuntos)
Un estado del DFA = un conjunto de estados del NFA que podrían estar activos simultáneamente.
Paso 3: Minimización del DFA
Eliminar estados equivalentes para obtener el DFA más pequeño posible.
Velocidad del lexer resultante: donde = longitud del texto — lineal. El DFA nunca hace backtrack.
Regla de prioridad en PLY:
Cuando dos reglas pueden matchear en la misma posición:
- La función más larga gana (máximo match).
- Entre funciones de igual longitud, gana la que aparece primero.
- Las funciones tienen mayor prioridad que las strings (por eso
IDENTIFICADORverifica si es palabra reservada).
🛠️ En la práctica
Manejo de errores léxicos útiles:
class LexerError(Exception):
def __init__(self, mensaje, linea, columna):
self.mensaje = mensaje
self.linea = linea
self.columna = columna
def __str__(self):
return f"Error léxico en línea {self.linea}, columna {self.columna}: {self.mensaje}"
def t_error(t):
linea = t.lexer.lineno
# Calcular columna
ultimo_nl = t.lexer.lexdata.rfind('\n', 0, t.lexpos)
columna = t.lexpos - ultimo_nl
char = t.value[0]
raise LexerError(f"Carácter no reconocido: '{char}'", linea, columna)
# Resultado para código con error:
# Error léxico en línea 7, columna 12: Carácter no reconocido: '@'
Tokens para el IDE: Los editores modernos usan lexers para syntax highlighting. El LSP (Language Server Protocol) expone el lexer al editor para colorear código en tiempo real.
2.4 Ejercicios
✏️ Ejercicio 2.1 — Clasificar tokens
Para el siguiente código PupusaScript, listá todos los tokens en orden con su tipo:
si descuento > 0 entonces
precio_final <- precio - (precio * descuento)
fin_si
Solución
| # | Token | Tipo |
|---|---|---|
| 1 | si |
SI |
| 2 | descuento |
IDENTIFICADOR |
| 3 | > |
GT |
| 4 | 0 |
NUM_ENTERO |
| 5 | entonces |
ENTONCES |
| 6 | precio_final |
IDENTIFICADOR |
| 7 | <- |
ASIGNACION |
| 8 | precio |
IDENTIFICADOR |
| 9 | - |
MENOS |
| 10 | ( |
LPAREN |
| 11 | precio |
IDENTIFICADOR |
| 12 | * |
MUL |
| 13 | descuento |
IDENTIFICADOR |
| 14 | ) |
RPAREN |
| 15 | fin_si |
FIN_SI |
✏️ Ejercicio 2.2 — Extender el lexer
Extendé el lexer de PupusaScript para soportar:
a. Números enteros en notación hexadecimal: 0x1F, 0xABC
b. Strings multilínea con comillas triples: """texto con\nsaltos""" (tres comillas dobles)
c. El operador de potencia **
Escribí la función o regex de PLY para cada uno.
Solución
# a. Hexadecimal (antes de NUM_ENTERO porque también empieza con 0)
def t_NUM_HEX(t):
r'0x[0-9A-Fa-f]+'
t.value = int(t.value, 16) # convertir a entero
t.type = 'NUM_ENTERO' # mismo tipo que entero decimal
return t
# b. String multilínea (antes de STRING porque es más largo)
def t_STRING_MULTILINEA(t):
r'"""[\s\S]*?"""' # [\s\S] = cualquier carácter incluyendo \n
t.value = t.value[3:-3] # remover las tres comillas
# Contar saltos de línea para actualizar lineno
t.lexer.lineno += t.value.count('\n')
t.type = 'STRING'
return t
# c. Operador de potencia (antes de MUL porque ** > *)
t_POTENCIA = r'\*\*' # agregar 'POTENCIA' a la lista de tokens
Orden importante: Las funciones y strings más largas deben definirse antes de las más cortas. En PLY, las funciones se ordenan por longitud de regex (más larga primero). Las strings se ordenan alfabéticamente, así que t_POTENCIA = r'\*\*' va antes de t_MUL = r'\*' solo si se define como función.
2.5 Para profundizar
- Aho, Lam, Sethi & Ullman, Compilers: Principles, Techniques, and Tools ("Dragon Book"), cap. 3.
- PLY documentation (dabeaz.com/ply).
- Siguiente: Análisis sintáctico.
Definiciones nuevas: token, lexema, analizador léxico (lexer), scanner, palabra reservada, PLY, máximo match, construcción de Thompson, construcción de subconjuntos, minimización de DFA, syntax highlighting, LSP.