Creando un DSL en Python: Cómo construí un intérprete de lenguaje ruso para mi proyecto de diploma
El intérprete de LawScript no es solo un experimento académico, es un DSL práctico con un subsistema imperativo, construido en Python para abordar retos legales. El autor combinó descripciones declarativas de contratos con lógica procedural para la validación de reglas, creando un lenguaje híbrido aprobado por el departamento como proyecto de tesis. En su núcleo: un parser sin lexer separado, un preprocesador con metadatos de línea y compilación a un AST serializado como "bytecode".
Arquitectura sin lexer: Cuando reinventar la rueda es una ventaja
El pipeline habitual de "lexer → parser → compiler → interpreter" se rompe deliberadamente aquí: el análisis léxico está incrustado directamente en el parser. Cada línea de código fuente se envuelve en una clase Line que almacena el número de línea, la ruta del archivo y el texto original. Esto preserva el contexto de errores y simplifica la depuración. Estructura de ejemplo:
class Info(NamedTuple):
num: int
file: str
raw_line: str
class Line(str):
def __new__(cls, value: str, num: int = 0, file: str = ""):
obj = str.__new__(cls, value)
obj.raw_data = value
obj.num = num
obj.file = file
return obj
def get_file_info(self) -> Info:
return Info(num=self.num, file=self.file, raw_line=self.raw_data)
El preprocesador maneja directivas VKLYuChIT, cargando recursivamente módulos del directorio actual o la biblioteca estándar. Soporta tres tipos de archivos: .raw (archivos fuente), .law (AST serializado), .pyl (extensiones de Python). Las importaciones se almacenan en caché para evitar reprocesarlas.
Análisis sintáctico por composición: Gramática como un conjunto de microservicios
El parser se implementa como una clase base abstracta, con parsers especializados que heredan de ella para cada constructo gramatical. Esto permite anidar parsers: al encontrar un constructo conocido, el parser actual delega en su hijo. El resultado es un árbol MetaObject, que luego se valida y convierte en objetos ejecutables.
El análisis léxico lo maneja el método separate_line_to_token, que divide la línea en tokens ignorando comentarios y verificando el balance de corchetes. En caso de errores, proporciona una pista posicional: un cursor apunta al símbolo problemático. Por ejemplo, una coma extra en una expresión:
if token_ == Tokens.right_bracket:
sub_expr = expr[offset:]
previous_tok = sub_expr[offset_ - 1]
if previous_tok == Tokens.comma:
err_expr = ''.join([str(i) for i in sub_expr][:offset_+1])
sub_expr = [str(i) for i in sub_expr]
res_expr = ''.join(str(i) for i in expr)
target_comma = (
f"{err_expr}\n"
f"{' ' * (sum(len(t) for o, t in enumerate(sub_expr) if o < offset_ - 1))}^"
)
raise InvalidExpression(
f"In vyrazhenii: '{res_expr}' worth lishnyaya zapyataya '{Tokens.comma}'\n\n"
f"{target_comma}\n"
)
Notación polaca inversa y expresiones complejas
Las expresiones de cualquier complejidad, incluidos argumentos por defecto, se compilan en una pila RPN. Esto maneja correctamente la precedencia de operadores y las llamadas a funciones anidadas. Por ejemplo, un procedimiento de ordenación de arrays:
OPREDELIT PROTsEDURU sortirovka_array(array_numbers) (
SET length = length_array(array_numbers);
SET minimum_index = 0;
LOOP index FROM 0 TO length-1 (
minimum_index = index;
LOOP internal_index FROM index+1 TO length-1 (
IF fetch_from_array(array_numbers, internal_index) MENShE fetch_from_array(array_numbers, minimum_index) THEN (
minimum_index = internal_index;
)
)
IF minimum_index NERAVNO index THEN (
SET temporary_variable = fetch_from_array(array_numbers, index);
change_in_array(array_numbers, index, fetch_from_array(array_numbers, minimum_index));
change_in_array(array_numbers, minimum_index, temporary_variable);
)
)
NAPEChATAT array_numbers;
)
Esto se transforma en un AST como este:
[
["AssignField", "TARGET", "length", "EXPR", [...]],
["AssignField", "TARGET", "minimum_index", "EXPR", [Number(0)]],
["Loop",
"FROM_EXPR", [Number(0)],
"TO_EXPR", [Service name: <length>, Number(1), Service name: <->],
[
["AssignOverrideVariable", "TARGET_EXPR", [...], "OVERRIDE_EXPR", [...]],
["Loop",
"FROM_EXPR", [...],
"TO_EXPR", [...],
[
["When", "EXPR", [...],
[["AssignOverrideVariable", ...]]
]
]
],
["When", "EXPR", [...]],
[["AssignField", ...], [...], [...]]
]
],
["Print", "EXPR", [Service name: <array_numbers>]]
]
Compilador y ejecución: Del árbol a las acciones
El compilador recorre el AST, transformando cada nodo en un objeto ejecutable. Por ejemplo, AssignField se convierte en una asignación de variable, Loop en un bucle con límites calculados en tiempo de ejecución. El intérprete ejecuta estos objetos de forma secuencial, gestionando una pila de llamadas y variables locales. Para reruns más rápidos, el AST se serializa en un archivo .law: el "bytecode", que se carga sin volver a analizar.
Lo más importante
- DSL de doble naturaleza: contratos declarativos + lógica imperativa = híbrido único para tareas legales.
- Metadatos por línea: la clase
Linepreserva el contexto para diagnósticos de errores precisos. - Composición del parser: cada constructo gramatical es su propia clase, facilitando las extensiones del lenguaje.
- RPN para expresiones: maneja aritmética compleja y argumentos por defecto sin dependencias externas.
- Serialización de AST: el archivo
.lawactúa como caché, acelerando los lanzamientos tras la compilación inicial.
— Editorial Team
Aún no hay comentarios.