Desarrollo de un Lexer con Test-Driven Development

Clase 5 de 58Curso de Creación de Lenguajes de Programación: Intérpretes

Contenido del curso

Construcción del lexer o tokenizador

Construcción del parser o analizador sintáctico

Evaluación o análisis semántico

Resumen

Escribir código que funcione desde el primer intento suena ideal, pero la realidad del desarrollo profesional es distinta. La metodología test-driven development (TDD) propone algo contraintuitivo: primero escribir los tests que van a fallar y luego implementar el código necesario para que pasen. En esta sesión se construye el primer lexer funcional siguiendo exactamente ese enfoque, leyendo carácter por carácter y generando tokens a partir de un source code de entrada.

¿Cómo se estructura un proyecto para hacer test-driven development?

Antes de escribir cualquier test, la estructura del proyecto debe estar lista. Se necesitan dos carpetas principales:

  • lpp: contiene el módulo lexer.py y un archivo __init__.py para que mypy lo reconozca como paquete.
  • test: contiene lexer_test.py y su propio __init__.py para que mypy y nosetests detecten los tests automáticamente.

Cada vez que se cambia de branch, es fundamental correr los tests con mypy . y nosetests para verificar que todo sigue funcionando [0:52]. Este hábito garantiza que no se arrastren errores entre ramas.

¿Qué patrón siguen todos los tests?

Todos los tests en TDD siguen la estructura assemble, act, assert [5:30]:

  • Assemble: se preparan los datos y se inicializa el objeto, en este caso el lexer.
  • Act: se ejecuta la acción que se quiere probar, como llamar a next_token.
  • Assert: se compara el resultado obtenido con el resultado esperado usando assert_equals.

¿Cómo se escriben los primeros tests para tokens ilegales?

El primer test verifica que el lexer identifique correctamente los tokens ilegales, es decir, caracteres que el lenguaje no permite. Se define una variable source con tres caracteres prohibidos: el signo de exclamación !, el signo de apertura de pregunta ¿ y la arroba @ [3:08].

Después se inicializa el lexer con ese source, se ejecuta next_token tantas veces como caracteres haya y se almacenan los resultados en una lista. Lo esperado es recibir tres tokens, todos de tipo TokenType.ILLEGAL, cada uno con su literal correspondiente.

python from unittest import TestCase from typing import List from lpp.token import Token, TokenType from lpp.lexer import Lexer

class LexerTest(TestCase): def test_illegal(self) -> None: source: str = '!¿@' lexer: Lexer = Lexer(source) tokens: List[Token] = [] for i in range(len(source)): tokens.append(lexer.next_token()) expected_tokens: List[Token] = [ Token(TokenType.ILLEGAL, '!'), Token(TokenType.ILLEGAL, '¿'), Token(TokenType.ILLEGAL, '@'), ] self.assertEqual(tokens, expected_tokens)

¿Por qué el test falla varias veces antes de pasar?

Esta es la esencia de TDD. Al correr el test por primera vez, el error dice que Lexer no existe [4:40]. Se crea la clase. Luego dice que next_token no existe. Se define el método. Después el test falla correctamente: regresa None en lugar de tokens [6:30]. Cada error es una guía que indica exactamente qué implementar a continuación.

¿Cómo funciona el método read character dentro del lexer?

El lexer lee el source carácter por carácter mediante el método privado _read_character. Este método utiliza dos variables internas [7:22]:

  • position: indica la posición actual del carácter que se está procesando.
  • read_position: apunta al siguiente carácter por leer.

La lógica es directa: si read_position es mayor o igual a la longitud del source, se asigna un string vacío al carácter (indicando el fin del archivo o EOF, End Of File). Si no, se extrae el carácter en esa posición. Después se avanza moviendo position al valor de read_position y se incrementa read_position en uno [7:50].

python class Lexer: def init(self, source: str) -> None: self._source: str = source self._character: str = '' self._read_position: int = 0 self._position: int = 0 self._read_character()

def next_token(self) -> Token: token = Token(TokenType.ILLEGAL, self._character) self._read_character() return token def _read_character(self) -> None: if self._read_position >= len(self._source): self._character = '' else: self._character = self._source[self._read_position] self._position = self._read_position self._read_position += 1

El método _read_character se ejecuta en el constructor para inicializar el primer carácter y se vuelve a llamar dentro de next_token antes de retornar, asegurando que el lexer siempre avance al siguiente carácter.

El método next_token es la interfaz principal del lexer [1:18]. Cada llamada devuelve el siguiente token disponible, lo que permite iterar sobre todo el archivo fuente de forma secuencial.

Los errores durante el desarrollo no son obstáculos, son la brújula que señala qué falta. Cuéntanos en los comentarios cómo te pareció esta forma de construir software donde primero fallas, corriges y avanzas paso a paso.

      Desarrollo de un Lexer con Test-Driven Development