Introducción a ANTLR4
ANTLR (Another Tool for Language Recognition) es una potente herramienta para la generación de analizadores sintácticos. Se sitúa en el front-end de la tecnología de compiladores, facilitando la creación de parsers, intérpretes y traductores.
ANTLR4, la versión más reciente, es compatible con la generación de analizadores para múltiples lenguajes de programación, incluyendo C#, Java, Python y JavaScript. Las reglas gramaticales de ANTLR4 se definen utilizando una sintaxis similar a las expresiones regulares, lo que permite describir estructuras gramaticales complejas de manera concisa.
El flujo de trabajo básico con ANTLR4 comprende los siguientes pasos:
- **Definición de la Gramática:** Se especifican las reglas del lenguaje en un archivo
.g4. - **Generación del Analizador:** ANTLR4 procesa el archivo
.g4para producir el código del parser en el lenguaje de destino. - **Ejecución del Análisis:** El código generado se utiliza para analizar texto de entrada, resultando típicamente en un Árbol de Sintaxis Abstracta (AST).
- **Recorrido del AST:** Se implementan patrones como Visitor o Listener para navegar el AST y ejecutar la lógica deseada, como interpretación o compilación.
Conceptos Fundamantales del Análisis Sintáctico
El proceso de análisis de un lenguaje se divide generalmente en dos fases principales:
- **Análisis Léxico (Lexical Analysis):** Esta etapa descompone la secuencia de caracteres de entrada en una serie de tokens, que son las unidades mínimas con significado. Por ejemplo, la expresión
1 + 2se transformaría en los tokens1,+y2. - **Análisis Sintáctico (Syntax Analysis):** Los tokens generados en la fase léxica se organizan en una estructura jerárquica conocida como Árbol de Sintaxis Abstracta (AST). Este árbol representa la estructura gramatical del texto de entrada, donde cada nodo simboliza una unidad sintáctica definida por una regla gramatical y puede contener nodos hijos.
Consideremos la expresión 1 + 2 * 3. Su AST correspondiente podría visualizarse de la siguiente manera:
+
/ \
1 *
/ \
2 3
Implementación de ANTLR4 en .NET
1. Incorporación del Paquete Antlr4.Runtime.Standard
Para comenzar, crearemos un nuevo proyecto C# y añadiremos el paquete Antlr4.Runtime.Standard. Este paquete proporciona las librerías necesarias para ejecutar los parsers generados por ANTLR4.
dotnet add package Antlr4.Runtime.Standard
2. Creación del Archivo de Reglas Gramaticales (.g4)
El siguiente paso es definir la gramática de nuestro lenguaje. Usaremos un archivo .g4. Como ejemplo, desarrollaremos un parser para expresiones aritméticas básicas. Nombrémoslo Calculadora.g4:
grammar Calculadora; // El nombre de la gramática debe coincidir con el del archivo
// Reglas del parser (sintácticas)
// El operador 'op' puede ser '*' o '/'
// '# MultiDiv' asigna un nombre a esta regla, útil para el recorrido del AST
exp: exp op=('*'|'/') exp # MultiDiv
| exp op=('+'|'-') exp # SumaResta
| NUMERO # NumeroEntero
| '(' exp ')' # Parentesis
;
// Reglas del lexer (léxicas)
NUMERO : [0-9]+ ;
ESPACIO : [ \t\r\n]+ -> skip ; // Ignora espacios en blanco
Un archivo .g4 contiene reglas léxicas y sintácticas. Las reglas léxicas (que comienzan con mayúscula) definen tokens como números u operadores. Las reglas sintácticas (que comienzan con minúscula) definen estructuras gramaticales como expresiones. En este ejemplo, la regla exp usa el operador | para indicar alternativas, y la prioridad de los operadores (multiplicación/división antes que suma/resta) se determina por su orden de aparición. El sufijo # NombreRegla proporciona un identificador para los nodos del AST, lo que simplifica su manipulación posterior.
3. Generación del Analizador Sintáctico
ANTLR4, desarrollado en Java, requiere un entorno de ejecución Java para generar el código del parser. Presentamos dos métodos para este proceso.
Método A: Utilizando la Herramienta Oficial de ANTLR4 Directamente
Descargue el JAR completo de ANTLR4 (por ejemplo, antlr-4.13.2-complete.jar) desde el sitio oficial de ANTLR. Coloque este archivo en el mismo directorio que su gramática .g4.
Luego, ejecute el siguiente comando:
java -jar antlr-4.13.2-complete.jar -Dlanguage=CSharp Calculadora.g4
Esto generará archivos como CalculadoraLexer.cs y CalculadoraParser.cs, que son las clases C# de su analizador.
Método B: Automatización con Antlr4BuildTasks
El proyecto Antlr4BuildTasks simplifica la generación de parsers integrándola en el proceso de compilación de .NET. Esta tarea MSBuild descarga automáticamente las herramientas de ANTLR4 y genera los archivos del parser.
Además de añadir el paquete Antlr4BuildTasks, modifique su archivo .csproj para incluir la siguiente configuración:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings<
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Antlr4.Runtime.Standard" Version="4.13.1"/>
<PackageReference Include="Antlr4BuildTasks" Version="12.8.0" PrivateAssets="all"/>
</ItemGroup>
<ItemGroup>
<Antlr4 Include="**\*.g4"/>
</ItemGroup>
</Project>
La línea <Antlr4 Include="**\*.g4"/> asegura que todos los archivos .g4 del proyecto sean procesados. Los archivos generados se ubicarán en el directorio obj y se regenerarán en cada compilación, por lo que no es necesario incluirlos directamente en el control de versiones.
4. Utilización del Analizador Sintáctico en Código C#
Una vez generados los archivos del parser, podemos usar dos patrones principales para recorrer el AST: el patrón Visitor o el patrón Listener.
El parser de ANTLR4 invoca métodos específicos (VisitXXX para Visitor o EnterXXX/ExitXXX para Listener) al atravesar los nodos del AST.
Recorrido con el Patrón Visitor
El patrón Visitor es ideal para cuando se necesita devolver un valor en cada nodo del AST o cuando se realizan transformaciones. ANTLR4 genera una clase base como CalculadoraBaseVisitor<TResult> que podemos extender.
Basándonos en los nombres de reglas de nuestro archivo Calculadora.g4 (MultiDiv, SumaResta, NumeroEntero, Parentesis), sborescribiremos los métodos correspondientes en nuestro Visitor:
using Antlr4.Runtime;
using System;
public class EvaluadorAritmetico : CalculadoraBaseVisitor<int>
{
public override int VisitMultiDiv(CalculadoraParser.MultiDivContext contexto)
{
// context.exp(0) y context.exp(1) acceden a los operandos
int operandoIzquierdo = Visit(contexto.exp(0));
int operandoDerecho = Visit(contexto.exp(1));
return contexto.op.Text switch
{
"*" => operandoIzquierdo * operandoDerecho,
"/" => operandoIzquierdo / operandoDerecho,
_ => throw new NotSupportedException($"Operador {contexto.op.Text} no soportado.")
};
}
public override int VisitSumaResta(CalculadoraParser.SumaRestaContext contexto)
{
int operandoIzquierdo = Visit(contexto.exp(0));
int operandoDerecho = Visit(contexto.exp(1));
return contexto.op.Text switch
{
"+" => operandoIzquierdo + operandoDerecho,
"-" => operandoIzquierdo - operandoDerecho,
_ => throw new NotSupportedException($"Operador {contexto.op.Text} no soportado.")
};
}
public override int VisitParentesis(CalculadoraParser.ParentesisContext contexto) => Visit(contexto.exp());
public override int VisitNumeroEntero(CalculadoraParser.NumeroEnteroContext contexto)
{
return int.Parse(contexto.NUMERO().GetText());
}
}
Ahora, podemos integrar este Visitor en nuestra aplicación para evaluar expresiones:
using Antlr4.Runtime;
Console.WriteLine(CalcularExpresion("1 + 2 * 3")); // 7
Console.WriteLine(CalcularExpresion("(1 + 2) * 3")); // 9
int CalcularExpresion(string expresion)
{
var flujoEntrada = new AntlrInputStream(expresion);
var analizadorLexico = new CalculadoraLexer(flujoEntrada);
var flujoTokens = new CommonTokenStream(analizadorLexico);
var analizadorSintactico = new CalculadoraParser(flujoTokens);
var visitor = new EvaluadorAritmetico();
return visitor.Visit(analizadorSintactico.exp()); // 'exp' es la regla inicial de nuestra gramática
}
Recorrido con el Patrón Listener
El patrón Listener se activa mediante eventos de entrada y salida de cada nodo del AST. Es útil cuando se desea ejecutar lógica sin devolver valores explícitos de los nodos, como construir una estructura de datos o realizar una validación.
ANTLR4 genera una clase base como CalculadoraBaseListener. Sobrescribiremos los métodos ExitXXX para realizar los cálculos a medida que salimos de los nodos.
using Antlr4.Runtime;
using System;
using System.Collections.Generic;
public class OyenteCalculadora : CalculadoraBaseListener
{
private readonly Stack<int> _pilaValores = new();
public int ResultadoFinal => _pilaValores.Pop();
public override void ExitMultiDiv(CalculadoraParser.MultiDivContext contexto)
{
int operandoDerecho = _pilaValores.Pop();
int operandoIzquierdo = _pilaValores.Pop();
int resultado = contexto.op.Text switch
{
"*" => operandoIzquierdo * operandoDerecho,
"/" => operandoIzquierdo / operandoDerecho,
_ => throw new NotSupportedException($"Operador {contexto.op.Text} no soportado.")
};
_pilaValores.Push(resultado);
}
public override void ExitSumaResta(CalculadoraParser.SumaRestaContext contexto)
{
int operandoDerecho = _pilaValores.Pop();
int operandoIzquierdo = _pilaValores.Pop();
int resultado = contexto.op.Text switch
{
"+" => operandoIzquierdo + operandoDerecho,
"-" => operandoIzquierdo - operandoDerecho,
_ => throw new NotSupportedException($"Operador {contexto.op.Text} no soportado.")
};
_pilaValores.Push(resultado);
}
public override void ExitParentesis(CalculadoraParser.ParentesisContext contexto)
{
// No se requiere acción aquí; el valor ya fue procesado por las reglas internas.
}
public override void ExitNumeroEntero(CalculadoraParser.NumeroEnteroContext contexto)
{
_pilaValores.Push(int.Parse(contexto.NUMERO().GetText()));
}
}
Uso del Listener:
using Antlr4.Runtime;
using Antlr4.Runtime.Tree;
Console.WriteLine(CalcularConOyente("1 + 2 * 3")); // 7
Console.WriteLine(CalcularConOyente("(1 + 2) * 3")); // 9
int CalcularConOyente(string expresion)
{
var flujoEntrada = new AntlrInputStream(expresion);
var analizadorLexico = new CalculadoraLexer(flujoEntrada);
var flujoTokens = new CommonTokenStream(analizadorLexico);
var analizadorSintactico = new CalculadoraParser(flujoTokens);
var arbolParseo = analizadorSintactico.exp(); // Obtenemos el árbol de parseo
var oyente = new OyenteCalculadora();
ParseTreeWalker.Default.Walk(oyente, arbolParseo); // Recorremos el árbol con el oyente
return oyente.ResultadoFinal;
}
Construyendo un AST Personalizado para Casos Complejos
Para escenarios de análisis más sofisticados que van más allá de una simple evaluación (como construir un optimizador o un generador de código), es preferible transformar el AST generado por ANTLR4 en un AST personalizado. Esto desacopla la fase de análisis de la lógica de procesamiento, lo que resulta en un código más modular y fácil de mantener.
Imaginemos un requisito donde necesitamos consultar archivos CSV dentro de una carpeta usando una sintaxis similar a SQL, con soporte para filtros y ordenación. El nombre del archivo CSV sería el nombre de la tabla, y los encabezados de las columnas, los campos de la tabla. Por simplicidad, solo admitiremos SELECT, FROM, WHERE y ORDER BY. Los tipos de datos serán tratados como cadenas, y las condiciones de filtro permitidas son =, !=, LIKE, conectadas únicamente con AND. La ordenación puede ser ASC o DESC.
Definiremos las reglas léxicas y sintácticas en archivos separados: SqlLexer.g4 y SqlParser.g4.
SqlLexer.g4:
lexer grammar SqlLexer;
options {
caseInsensitive = true; // Ignorar mayúsculas/minúsculas
}
// Palabras clave
SELECT : 'SELECT' ;
FROM : 'FROM' ;
WHERE : 'WHERE' ;
ORDER : 'ORDER' ;
BY : 'BY' ;
ASC : 'ASC' ;
DESC : 'DESC' ;
AND : 'AND' ;
OR : 'OR' ;
COMA : ',' ;
ASTERISCO : '*' ;
// Operadores
IGUAL : '=' ;
DISTINTO : '!=' ;
SIMILAR : 'LIKE' ;
// Literales
CADENA_LITERAL
: '\'' ( ~('\'' | '\\') | '\\' . )* '\'' // Literal de cadena
;
// Identificadores
IDENTIFICADOR
: [a-z_][a-z0-9_]* // Para nombres de tabla, columnas, etc.
;
ESPACIO : [ \t\r\n]+ -> skip ; // Ignorar espacios en blanco
SqlParser.g4:
parser grammar SqlParser;
options { tokenVocab=SqlLexer; }
consulta
: SELECT listaSeleccion FROM nombreTabla (WHERE clausulaWhere)? (ORDER BY clausulaOrden)?
;
listaSeleccion
: nombreColumna (COMA nombreColumna)*
| ASTERISCO
;
nombreColumna: IDENTIFICADOR ;
nombreTabla: IDENTIFICADOR ;
clausulaWhere
: condicionWhere (AND condicionWhere)*
;
condicionWhere
: nombreColumna op=(IGUAL | DISTINTO) CADENA_LITERAL
| nombreColumna op=SIMILAR CADENA_LITERAL
;
clausulaOrden
: condicionOrden (COMA condicionOrden)*
;
condicionOrden
: nombreColumna (ASC | DESC)?
;
A continuación, definimos las clases para nuestro AST personalizado en C#:
using System.Collections.Generic;
public abstract class Expresion;
public class ExpresionConsulta : Expresion
{
public required string NombreTabla { get; init; }
public required bool SeleccionarTodo { get; init; }
public required IEnumerable<string> ColumnasSeleccionadas { get; init; }
public required IEnumerable<CondicionFiltro> CondicionesFiltro { get; init; }
public required IEnumerable<CondicionOrden> CondicionesOrdenacion { get; init; }
}
public class CondicionFiltro : Expresion
{
public required string NombreColumna { get; init; }
public OperadorCondicionFiltro Operador { get; init; }
public required string Valor { get; init; }
}
public enum OperadorCondicionFiltro
{
Igual,
Distinto,
EmpiezaCon,
TerminaCon,
Contiene
}
public class CondicionOrden : Expresion
{
public required string NombreColumna { get; init; }
public bool EsDescendente { get; init; }
}
Ahora, implementamos un Visitor para construir nuestro AST personalizado a partir del AST de ANTLR4:
using System;
using System.Linq;
public class ConstructorSqlAst : SqlParserBaseVisitor<ExpresionConsulta>
{
public override ExpresionConsulta VisitConsulta(SqlParser.ConsultaContext contexto)
{
var listaSeleccion = contexto.listaSeleccion();
bool seleccionarTodo = listaSeleccion?.ASTERISCO() != null;
var columnas = listaSeleccion?.nombreColumna()
.Select(c => c.GetText())
.ToList() ?? [];
var nombreTabla = contexto.nombreTabla().GetText();
var condicionesFiltro = contexto.clausulaWhere()
?.condicionWhere()
.Select(c =>
{
var valorCadena = c.CADENA_LITERAL().GetText().Trim('\'');
var textoOperador = c.op.Text.ToUpperInvariant();
var operador = OperadorCondicionFiltro.Igual;
if (textoOperador == "=")
{
operador = OperadorCondicionFiltro.Igual;
}
else if (textoOperador == "!=")
{
operador = OperadorCondicionFiltro.Distinto;
}
else if (textoOperador == "LIKE")
{
if (valorCadena.StartsWith("%") && valorCadena.EndsWith("%"))
{
operador = OperadorCondicionFiltro.Contiene;
valorCadena = valorCadena.Substring(1, valorCadena.Length - 2);
}
else if (valorCadena.StartsWith("%"))
{
operador = OperadorCondicionFiltro.TerminaCon;
valorCadena = valorCadena.Substring(1);
}
else if (valorCadena.EndsWith("%"))
{
operador = OperadorCondicionFiltro.EmpiezaCon;
valorCadena = valorCadena.Substring(0, valorCadena.Length - 1);
}
}
else
{
throw new NotSupportedException($"Operador {c.op.Text} no soportado.");
}
return new CondicionFiltro
{
NombreColumna = c.nombreColumna().GetText(),
Operador = operador,
Valor = valorCadena
};
})
.ToList() ?? [];
var condicionesOrdenacion = contexto.clausulaOrden()
?.condicionOrden()
.Select(c => new CondicionOrden
{
NombreColumna = c.nombreColumna().GetText(),
EsDescendente = c.DESC() != null
})
.ToList() ?? [];
return new ExpresionConsulta
{
SeleccionarTodo = seleccionarTodo,
ColumnasSeleccionadas = columnas,
NombreTabla = nombreTabla,
CondicionesFiltro = condicionesFiltro,
CondicionesOrdenacion = condicionesOrdenacion
};
}
}
Finalmente, un motor para ejecutar las consultas SQL contra archivos CSV:
using Antlr4.Runtime;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
public class MotorSqlCsv(DirectoryInfo directorioCsv)
{
public IEnumerable<Dictionary<string, string>> EjecutarConsulta(string consultaSql)
{
var flujoEntrada = new AntlrInputStream(consultaSql);
var analizadorLexico = new SqlLexer(flujoEntrada);
var flujoTokens = new CommonTokenStream(analizadorLexico);
var analizadorSintactico = new SqlParser(flujoTokens);
var constructorAst = new ConstructorSqlAst();
var expresionAst = constructorAst.Visit(analizadorSintactico.consulta());
if (expresionAst is not ExpresionConsulta queryExpression)
{
throw new InvalidOperationException("Se esperaba una expresión de consulta válida.");
}
var datosCsv = LeerCsv(queryExpression.NombreTabla);
var datosFiltrados = datosCsv.Where(fila =>
{
bool coincide = true;
foreach (var condicion in queryExpression.CondicionesFiltro)
{
if (fila.TryGetValue(condicion.NombreColumna, out var valorColumna))
{
coincide = condicion.Operador switch
{
OperadorCondicionFiltro.Igual => valorColumna == condicion.Valor,
OperadorCondicionFiltro.Distinto => valorColumna != condicion.Valor,
OperadorCondicionFiltro.EmpiezaCon => valorColumna.StartsWith(condicion.Valor),
OperadorCondicionFiltro.TerminaCon => valorColumna.EndsWith(condicion.Valor),
OperadorCondicionFiltro.Contiene => valorColumna.Contains(condicion.Valor),
_ => throw new ArgumentOutOfRangeException()
};
}
else
{
throw new InvalidOperationException($"La columna '{condicion.NombreColumna}' no existe en el archivo CSV.");
}
if (!coincide) break; // Si una condición no se cumple, no es necesario verificar las demás
}
return coincide;
});
IOrderedEnumerable<Dictionary<string, string>> datosOrdenados = null;
foreach (var condicionOrden in queryExpression.CondicionesOrdenacion)
{
Func<IEnumerable<Dictionary<string, string>>, Func<Dictionary<string, string>, string>,
IOrderedEnumerable<Dictionary<string, string>>> funcOrden;
if (datosOrdenados != null)
{
funcOrden = condicionOrden.EsDescendente
? (_, keySelector) => datosOrdenados.ThenByDescending(keySelector)
: (_, keySelector) => datosOrdenados.ThenBy(keySelector);
}
else
{
funcOrden = condicionOrden.EsDescendente
? Enumerable.OrderByDescending
: Enumerable.OrderBy;
}
datosOrdenados = funcOrden(datosFiltrados, fila =>
{
if (fila.TryGetValue(condicionOrden.NombreColumna, out var valor))
{
return valor;
}
throw new InvalidOperationException($"La columna de ordenación '{condicionOrden.NombreColumna}' no existe en el archivo CSV.");
});
datosFiltrados = datosOrdenados; // Asegurarse de que los datos filtrados se actualicen para el siguiente ThenBy
}
var resultadoFinal = datosOrdenados ?? datosFiltrados;
if (queryExpression.SeleccionarTodo)
{
return resultadoFinal;
}
var datosSeleccionados = resultadoFinal.Select(fila =>
{
var filaSeleccionada = new Dictionary<string, string>();
foreach (var nombreColumna in queryExpression.ColumnasSeleccionadas)
{
if (fila.TryGetValue(nombreColumna, out var valor))
{
filaSeleccionada[nombreColumna] = valor;
}
else
{
throw new InvalidOperationException($"La columna '{nombreColumna}' no existe en el archivo CSV.");
}
}
return filaSeleccionada;
});
return datosSeleccionados;
}
private IEnumerable<Dictionary<string, string>> LeerCsv(string nombreTabla)
{
var archivoCsv = new FileInfo(Path.Combine(directorioCsv.FullName, $"{nombreTabla}.csv"));
if (!archivoCsv.Exists)
{
throw new FileNotFoundException($"El archivo CSV '{archivoCsv.FullName}' no fue encontrado.");
}
using var lector = new StreamReader(archivoCsv.FullName);
var lineaEncabezado = lector.ReadLine();
if (lineaEncabezado == null)
{
throw new InvalidOperationException($"El archivo CSV '{archivoCsv.FullName}' está vacío.");
}
var encabezados = lineaEncabezado.Split(',');
while (!lector.EndOfStream)
{
var linea = lector.ReadLine();
if (linea == null) continue;
var valores = linea.Split(',');
yield return encabezados.Zip(valores, (h, v) => new { Key = h, Value = v })
.ToDictionary(x => x.Key, x => x.Value);
}
}
}
Para probar este motor, utilizaremos un archivo CSV llamado Empleados.csv con el siguiente contenido:
Name,City,Occupation,Company
Alice,New York,Engineer,TechCorp
Bob,Los Angeles,Designer,Creative Inc
Ben,Atlanta,Writer,Publishing House
Charlie,Chicago,Manager,Finance Group
David,Houston,Teacher,School District
Eve,Miami,Student,University
Frank,Seattle,Chef,Restaurant Co
Grace,San Francisco,Doctor,HealthCare
Hannah,Boston,Lawyer,Legal Partners
Ian,Denver,Architect,BuildIt
Y el código de prueba en C#:
using System.IO;
using System.Linq;
var rutaDirectorioCsv = new DirectoryInfo("/ruta/a/su/carpeta/csv"); // Asegúrese de cambiar esto a su ruta real
var motor = new MotorSqlCsv(rutaDirectorioCsv);
var consultaSql =
"""
SELECT Name, City, Occupation
FROM Empleados
WHERE City != 'Miami'
AND Occupation LIKE '%er'
ORDER BY Name ASC
""";
var resultados = motor.EjecutarConsulta(consultaSql).ToList();
if (resultados.Any())
{
// Imprimir encabezados
foreach (var columna in resultados.First().Keys)
{
Console.Write($"{columna}\t");
}
Console.WriteLine();
// Imprimir datos
foreach (var fila in resultados)
{
foreach (var (_, valor) in fila)
{
Console.Write($"{valor}\t");
}
Console.WriteLine();
}
}
else
{
Console.WriteLine("No se encontraron resultados para la consulta.");
}
La salida esperada sería:
Name City Occupation
Alice New York Engineer
Ben Atlanta Writer
Bob Los Angeles Designer
Charlie Chicago Manager
David Houston Teacher
Hannah Boston Lawyer