Análisis sintáctico de MQL usando las herramientas de MQL

Stanislav Korotky | 16 mayo, 2019

Introducción

Esencialmente, la programación consiste en la formalización y automatización de algunos procesos usando los lenguajes de aplicación general o especial. Gracias al lenguaje «built-in» MQL, la plataforma comercial MetaTrader permite implementar la programación para resolver diversas tareas del trader. Normalmente, el proceso de la programación se basa en el análisis y procesamiento de los datos aplicados de acuerdo con las reglas establecidas en los códigos fuente. Sin embargo, a veces surge la necesidad de realizar el análisis y procesamiento de estos mismos códigos fuente. Aquí, tenemos algunos ejemplos.

Una de las tareas más reclamadas y fáciles de entender es la búsqueda semántica y contextual en la base de los códigos fuente. Naturalmente, se puede buscar las cadenas (string) en el código fuente como en el texto normal, pero, en este caso, se pierde la semántica de lo que se busca. Y eso que en caso de los códigos fuente, es deseable distinguir la especificidad del uso de una subcadena en cada caso particular. Si un programador quiere encontrar donde se usa cada determinada variable, por ejemplo, «notification», la búsqueda simple por su nombre puede dar datos de más si la cadena se encuentra en otros valores: como el nombre del método, literal o en comentarios.

En cuanto a los proyectos grandes, generalmente, una de las tareas más complejas y reclamadas consiste en identificar la estructura del código, las dependencias y la jerarquía de las clases. Esta tarea está estrechamente unida con la meta-programación, que permite realizar la refactorización del código (la mejora) y la generación del código. Recordaremos que MetaEditor ofrece algunas posibilidades de la generación del código. En particular, se trata de la creación de los códigos fuente de los EAs a través del Asistente, o la generación del archivo de cabecera del código fuente. Sin embargo, las posibilidades potenciales de esta tecnología son bastante más amplias.

El análisis de la estructura del código permite calcular varias métricas de su calidad, estadística, así como, encontrar las fuentes típicas de los errores runtime que no pueden ser detectadas por el compilador. Está claro que, en realidad, el propio compilador es la primera herramienta del análisis del código fuente, y salta muchos tipos de avisos, pero la verificación de todos los posibles errores normalmente no está incorporada en él, es que, es una tarea bastante volumétrica, por tanto, la atribuyen a los programas ajenos.

Aparte de eso, el análisis sintáctico de los códigos fuente se usa para la estilización (formateo) y la ofuscación.

Para los lenguajes de programación industriales, hay muchas herramientas que realizan estas tareas. En caso de MQL, la elección es limitada. Podemos intentar realizar el análisis de MQL usando los medios disponibles mediante el ajuste que pone MQL al mismo nivel que C++. Eso funciona bastante fácil en caso de algunas herramientas, como Doxygen, pero requiere una adaptación más profunda para las herramientas más potentes, como lint-а, puesto que MQL no es C++.

Además, cabe mencionar que en este artículo hablaremos sólo del análisis estático del código, mientras que existen los analizadores dinámicos que permiten rastrear los errores del trabajo con la memoria, bloqueo de los flujos, corrección de los valores de las variables y mucho más en el ambiente virtual en tiempo real.

Para el análisis estático del código fuente se puede usar diferentes enfoques. En caso de las tareas simples, por ejemplo, para buscar las variables de entrada de un programa MQL, basta con usar la librería de las expresiones regulares. De una forma más regular, el análisis tiene que construirse a base del analizador sintáctico que considera la gramática del MQL. En este artículo, vamos a estudiar precisamente este enfoque e intentaremos aplicarlo en la práctica.

En otras palabras, escribiremos un analizador sintáctico MQL en el lenguaje MQL y obtendremos los metadatos sobre los códigos fuente. Eso no sólo permitirá resolver los problemas encima mencionados, sino también va a abrir otras posibilidades fantásticas en el futuro. Así, teniendo un analizador totalmente correcto, podríamos hacer a su base un interpretador MQL o convertir automáticamente MQL en los lenguajes de trading o viceversa (así llamado transpilado). Pero no he usado la palabra «fantástico» por algo. Aunque todas estas técnicas ya se usan ampliamente en otras áreas, para aproximarse a ellas en la plataforma MetaTrader, primero, hay que entender lo básico.


Visión general de la técnica

Hay varios analizadores sintácticos. No vamos a entrar en detalles técnicas aquí, es decir, Usted puede encontrar la información introductoria en Wikipedia, además, hay una enorme cantidad de recursos para un estudio más profundo.

Nótese que el analizador sintáctico funciona a base de una gramática que describe el lenguaje. Una de las formas más habituales de la descripción de las gramáticas es la de Backus-Naur (BNF). Hay varias modificaciones de BNF, por eso, no vamos a concentrar la atención en los detalles, simplemente consideraremos los momentos principales.

En BNF, todas las construcciones integrantes del lenguaje se denotan como así llamados «no terminales», mientras que las secuencias no separadas se denotan como los «terminales». Aquí, el término «terminal» es un punto final en el análisis sintáctico del texto, es decir, se trata de un tipo de token que contiene un fragmento del código fuente «tal como es» y que se interpreta en su totalidad. Por ejemplo, puede tratarse de un signo «coma», un corchete o una palabra separada. Definimos la lista de los terminales (alfabeto) dentro de la gramática. Todas las demás partes del programa se compilan a partir de los terminales de acuerdo con ciertas reglas.

Por ejemplo, en una notación BNF simplificada, se puede especificar que el programa se compone de los operadores de la siguiente manera:

program ::= operator more_operators
more_operators ::= program | {vacío}

Aquí, se dice que el «no terminal» program puede incluir uno o más operadores, siendo que los siguientes operadores se describen a través de una referencia recursiva a program. El carácter '|' (sin comillas en BNF) significa O lógico, es decir, selección de una de las opciones. Para completar la recursión, en la entrada presentada, se usa el terminal especial {vacío}. Se puede presentarlo como una cadena vacía o como la posibilidad de omitir la regla.

El símbolo operator también es un «no terminal» y requiere la «expansión» a través de otros no terminales y terminales, por ejemplo, así:

operator ::= name '=' expression ';'

Esta entrada especifica que cada operador tiene que empezar con el nombre de la variable, luego viene el signo '=', alguna expresión, y el operador se termina con el signo ';'. Los signos '=' y ';' son terminales. El nombre se compone de las letras:

name ::= letter more_letters
more_letters ::= name | {пусто}
letter ::= [A-Z]

Aquí, como letra, se permite usar cualquier carácter de 'A' a 'Z' (su conjunto se indica con los corchetes).

Supongamos que la expresión se construye a partir de los operandos y las operaciones aritméticas:

expression ::= operand more_operands
more_operands ::= operation expression | {vacío}

En el caso más simple, la expresión es el único operando, pero puede que haya más (more_operands), entonces, ellos se adjuntan a él a través del símbolo de la operación como una subexpresión. Supongamos que los operandos pueden ser las referencias a las variables o números, mientras que las operaciones, '+' o '-':

operand ::= name | number
number ::= digit more_digits
more_digits ::= number | {vacío}
digit ::= [0-9]
operation ::= '+' | '-'

De esta manera, hemos descrito la gramática de un lenguaje simple en el que se puede realizar los cálculos de los números y las variables, por ejemplo:

A = 10;
X = A - 5;

Cuando empezamos a analizar el texto, en realidad, verificamos cuáles de las reglas funcionan. Tarde o temprano, las reglas accionadas tienen que dar un cierto «producto», es decir, detectar un terminal que coincida con el contenido en la posición actual en el texto, mientras que el cursor se mueve a la siguiente posición. Este proceso se repite hasta que el texto entero se encuentre en la correspondencia por fragmentos con los no terminales, formando la secuencia permitida por la gramática.

En el ejemplo presentado antes, el analizador sintáctico, con el carácter 'A' en la entrada, empieza a repasar las reglas en el siguiente orden:

program
  operator
    name
      letter
        'A'

y encontrará la primera correspondencia. El cursor se desplaza al siguiente símbolo '='. Puesto que letter es una letra, el analizador volverá en la regla name. Y puesto que el nombre name puede componerse sólo de las letras, la alternativa more_letters no funciona (se selecciona como {vacío}), y el analizador vuelve en la regla operator, donde el terminal '=' va después del nombre. Será la segunda correspondencia. Luego, abriendo la regla expression, el analizador encontrará el operando, es decir, el número entero 10 (como una secuencia de dos nómeros), y finalmente, el punto y coma completará el análisis de la primera cadena. Según sus resultados, prácticamente vamos a saber el nombre de la variable, contenido de la expresión, es decir, el hecho de que se compone de un número, y su valor. La segunda cadena se analiza de la misma manera.

Es importante notar que la gramática del mismo lenguaje puede escribirse de diferentes maneras, formalmente van a corresponder una a otra, pero al seguir literalmente las reglas, en algunas ocasiones, pueden surgir problemas. Por ejemplo, la descripción de un número puede ser representada de la siguiente manera:

number ::= number digit | {vacío}

Esta forma de la entrada se llama la recursión izquierda, es decir, cuando el no terminal number se encuentra no sólo en la parte izquierda, sino también en la parte derecha, que define las reglas de su «producto», y él va primero a la izquierda en la cadena (de aquí proviene la denominación «recursión izquierda»). La recursión izquierda explícita es la más simple. Pero también puede ser implícita si el no terminal se revela a través de varias reglas intermedias en una cadena que comienza con el mismo.

La recursión izquierda se encuentra con frecuencia en las notaciones BNF de las gramáticas de los lenguajes de programación. No obstante, algunos tipos de los analizadores (dependiendo de la implementación) pueden concentrarse en semejantes reglas. De hecho, si la regla se considera como una guía para acción (algoritmo del analizador), esta entrada va a entrar repetidamente en number de forma recursiva, sin leer nuevos terminales desde el flujo de entrada, lo que, en teoría, tendría que pasar al abrir el no terminal digit.

Como intentaremos crear la gramática de MQL no partiendo de cero, sino usando, a ser posible, las notaciones BNF de la gramática C++, habrá que prestar atención en las recursiones izquierdas y reescribir las reglas de manera alternativa. Al mismo tiempo, habrá que implementar los medios de protección contra el efecto del ciclo (looping). Como veremos en adelante, las gramáticas de los lenguajes C++ o MQL son tan ramificadas que resulta imposible comprobar manualmente si son correctas o no.

Aquí, conviene mencionar que la escritura de los analizadores sintácticos es una verdadera ciencia, y es deseable empezar a dominar este campo paso a paso, de lo simple a lo complejo. El analizador más simple es el analizador sintáctico descendente recursivo. Él considera el texto de entrada como una integridad que corresponde al no terminal inicial de la gramática. En el ejemplo de arriba, era el no terminal program. Siguiendo cada regla apropiada, el analizador verifica que la secuencia de caracteres de entrada coincida con los terminales, y avanza por el texto al detectar las coincidencias. Si en algún momento, el analizador encuentra una discrepancia, él retrocede hasta las reglas donde se especifica un alternativa, y así, verifica todas las posibles opciones de las construcciones del lenguaje. Este algoritmo repite completamente las acciones que realizamos de forma puramente contemplativa en el ejemplo de arriba.

La operación del «retroceso» se llama backtracking en inglés, y puede afectar adversamente la velocidad. Por tanto, el analizador descendente recursivo clásico, en peor de los casos, genera un número de las alternativas exponencialmente creciente al examinar el texto. Para resolver este problema, existen las modificaciones, en particular, un analizador sintáctico predictivo que no requiere los «retrocesos». Su tiempo de ejecución es lineal.

Por otra parte, eso es posible sólo para las gramáticas, para las cuales se puede seleccionar univocamente una regla del «producto» según un determinado número de los caracteres K siguientes. Estos analizadores más avanzados se basan en su trabajo en unas tablas especiales de saltos, calculadas previamente usando todas las reglas de la gramática. Entre ellos, por ejemplo, se encuentra el analizador LL y analizador LR (aunque la lista no se limita con ellos).

LL significa Left-to-right, Leftmost derivation, es decir, el texto se ve de izquierda a derecha, y las reglas también se ven de izquierda a derecha, lo que equivale a la conclusión descendente (de lo general a lo particular), y en este sentido, LL es un pariente de nuestro analizador descendente.

LR significa Left-to-right, Rightmost derivation. Es decir, el texto se ve de izquierda a derecha, como antes, mientras que las reglas se ven de derecha a izquierda, lo que equivale a la construcción ascendente del lenguaje: de los caracteres individuales a los no terminales más grandes. A propósito, LR tiene menos problemas con la recursión izquierda.

Normalmente, en los nombres de los analizadores LL(k) y LR(k) se indica el número de los caracteres k, por el cual ellos examinan el texto hacia adelante, y en la mayoría de los casos, basta con elegir k = 1. Pero esta suficiencia es bastante condicional. Es que la mayoría de los lenguajes de programación modernos, incluyendo C++ y MQL, no son lenguajes con la gramática libre de contexto. En otras palabras, los mismos fragmentos del texto pueden interpretarse de forma diferente, dependiendo del contexto. En estos casos, para tomar una decisión sobre el significado de lo escrito, puede que un carácter o un número aleatorio de caracteres no sea suficiente, y es necesario vincular el analizador con otras herramientas, por ejemplo, con el preprocesador o con la tabla de los nombres (lista de los identificadores ya reconocidos y su significado).

Para el lenguaje C++ hay un caso simplista de la ambigüedad (también conviene para MQL). ¿Qué significa la expresión de abajo?

x * y;

Puede ser el producto de las variables X y Y, o bien puede ser la descripción de la variable y como un puntero tipo X. No se preocupe por el hecho de que el resultado de la multiplicación (si éste es el caso) no se guarde en ningún sitio, porque la operación de la multiplicación puede estar sobrecargada y tener los efectos secundarios.

Otro problema que afectaba la mayoría de los compiladores de C++ en el pasado, fue la ambigüedad en la interpretación de dos caracteres '>' seguidos. Es que después de la introducción de las plantillas, en los códigos fuente empezaron a aparecer las construcciones tipo:

vector<pair<int,int>> v;

Mientras que la secuencia '>>' fue inicialmente definida como el operador del desplazamiento. Durante algún tiempo, había que escribir estas expresiones usando el carácter del espacio en blanco, hasta que en los compiladores no fue introducido un procesamiento más fino de estos casos específicos:

vector<pair<int,int> > v;

En nuestro analizador, nosotros también tenemos que eliminar este problema.

En general, según esta breve introducción, seguramente quede claro que la descripción y la implementación de los analizadores avanzados exigen grandes esfuerzos (tanto en términos del volumen de la expresión, como en el tiempo para su estudio). Por esta razón, en este artículo, nos limitaremos con un analizador más simple (analizador descendente recursivo).

Planeamiento

La tarea del analizador consiste en leer el texto de entrada, dividirlo en un flujo de fragmentos inseparables (tokens), y luego, compararlos con las construcciones permitidas del lenguaje descritas a través de la gramática MQL en la notación BNF, o una notación próxima a ella.

Primero, vamos a necesitar una clase para leer los archivos, la llamaremos FileReader. Puesto que el proyecto MQL puede componerse de varios archivos incluidos desde el archivo principal a través de la directiva #include, podemos necesitar muchos archivos FileReader, y para controlarlos, definiremos una nueva clase, FileReaderController.

El texto de los archivos procesados, en realidad, es una cadena común. No obstante, necesitaremos traspasarla entre diferentes clases. Lamentablemente, MQL no admite los punteros a las cadenas (recordamos que hay referencias references, pero no se puede usarlas al declarar los miembros de la clase, mientras que la única opción de encaminar una referencia para todos los métodos a través de los parámetros de entrada es poco conveniente). Por tanto, vamos a crear una clase separada Source que representa un envoltorio para la cadena. Además, va a ejecutar otra función importante.

Es que, como resultado de la inclusión de los archivos include (y como consecuencia, de la lectura recursiva de los archivos de cabecera desde las dependencias), vamos a obtener el texto combinado de todos los archivos en la salida del controlador. Para diagnosticar los errores, necesitaremos obtener el nombre y la cadena del archivo original de donde proviene este texto, según el desplazamiento en el código fuente combinado. Este «mapa» de la colocación de los códigos fuente en los archivos también será soportado y almacenado por la clase Source.

Aquí tenemos una pregunta oportuna, ¿será posible procesar cada archivo por separado en vez de combinar los códigos fuente? Pues sí, probablemente, eso sea lo más correcto, pero en este caso sería necesario crear su propia instancia del analizador para cada archivo, y luego, de algún modo, coser los árboles sintácticos que se generan por el analizador en la salida. Por eso, decidí combinar los códigos fuente y enviarlos en un único analizador. Los interesados pueden experimentar con un enfoque alternativo.

Para que FileReaderController pueda encontrar las directivas #include, no sólo es necesario leer el texto desde los archivos, sino también hacer una visualización previa buscando estas directivas. Así, necesitamos una especie del preprocesador. En MQL, él realiza otras cosas útiles, en particular, permite definir las macros y luego sustituirlas por las expresiones reales (además, tomando en cuenta una recursión potencial de la llamada a una macro desde otra macro). Pero, en nuestro primer analizador sintáctico MQL, es mejor no centrarse en todas las tareas a la vez. Por eso, no vamos a procesar las macros en nuestro preprocesador, ya que eso no sólo exige describir adicionalmente la gramática de las macros, sino también interpretarlas «sobre la marcha», con el fin de tener las expresiones correctas para colocarlas en el código fuente. ¿Recuerda que hemos hablado del interpretador en la introducción? Pues bien, aquí ya sería conveniente, y en adelante comprenderemos por qué es importante. Es la orientación número 2 para los experimentos independientes.

El preprocesador se implementa en la clase Preprocessor. En su nivel, tiene lugar un proceso algo contradictorio. Durante la lectura de los archivos y la búsqueda de las directivas #include en ellos, el análisis y el avance en el texto se ejecuta en el menor nivel, o sea, carácter por carácter. Sin embargo, el preprocesador omite de forma transparente todo lo que no es una directiva, operando con los bloques mayores en la salida (archivos enteros o fragmentos de los archivos entre las directivas). Pero en adelante, el análisis va a realizarse en un nivel medio, para cuya descripción necesitaremos introducir un par de términos.

En primer lugar, es el lexema, es decir, una unidad mínima abstracta del análisis léxico, una subcadena de longitud diferente a cero. En combinación, suelen usar otro término, token, que también es una unidad del análisis, pero es concreta, no abstracta. Ambas unidades representan algún fragmento del texto (por ejemplo, un carácter, palabra o incluso un bloque de comentarios). Una diferencia imperceptible entre ellos consiste en el hecho de que, en el nivel de los tokens, marcamos los fragmentos con significado. Por ejemplo, si el texto contiene la palabra "int", para MQL es un lexema que se denota con el token INT, como un elemento en la enumeración de todos los tokens válidos del lenguaje MQL. En otras palabras, el conjunto de lexemas puede entenderse como un diccionario de cadenas que corresponden a los tipos de los tokens.

Una de las ventajas de los tokens es que permiten dividir el texto en unos fragmentos más grandes que los caracteres. Como resultado, el texto se analiza en dos pasos: primero, se forman los tokens del nivel más alto del flujo de las letras, luego, a su base, se analizan las construcciones del lenguaje. Eso permite simplificar considerablemente la gramática del lenguaje y el trabajo del analizador sintáctico.

Aquí, la clase especial Scanner va a seleccionar los tokens en el texto. Puede considerarse como un analizador de bajo nivel con una gramática predefinida que procesa el texto por letras. Más abajo consideraremos los tipos de los tokens necesarios. Si alguien elige el experimento número 1 (con la carga de cada archivo en un analizador seleccionado), se puede combinar el preprocesador con el escáner, y al detectar el token "#include <algo>", crear un nuevo FileReader, nuevo escáner, nuevo analizador y pasarles el control.

Los tokens MQL representarán todas las palabras clave (reservadas), signos de puntuación y las operaciones. La lista completa de las palabras clave MQL se adjunta en el archivo reserved.txt y se incluye en el código fuente del escáner.

Además, los identificadores, números, cadenas, literales y las demás constantes (por ejemplo, fechas) también serán los tokens independientes.

Durante el análisis del texto por los tokens, todos los espacios, avance de líneas, tabulaciones se suprimen (se ignoran). La única excepción que hay que hacer en forma de un procesamiento especial, se refiere a los avances de líneas por que su cálculo permite indicar una cadena con error si éste surge.

De esta manera, si pasamos un texto combinado a la entrada del escáner, obtendremos una lista de los tokens en la salida. Precisamente esta lista de los tokens será procesada en el analizador sintáctico que vamos a implementar en la clase Parser.

Para interpretar los tokens de acuerdo con las reglas del lenguaje MQL, hay que transferir la gramática en la notación BNF al analizador sintáctico. Para describir la gramática, intentaremos repetir de manera simplificada el enfoque usado por el analizador boost::spirit. La esencia consiste en describir las reglas de la gramática a través de las expresiones del lenguaje MQL gracias a la sobrecarga de algunos operadores.

Para eso, introducimos la jerarquía de las clases Terminal, NonTerminal, y sus clases derivadas. Terminal será su clase base, que, por defecto, corresponde a un único token. Como ha sido dicho en la parte teórica, el terminal es un elemento final e indivisible del análisis de las reglas: si en la posición actual se encuentra un carácter que corresponde al token del terminal, entonces, el símbolo corresponde a la gramática, se puede leerlo y seguir adelante.

NonTerminal va a usarse para las construcciones compuestas, donde se puede usar los terminales y otros no terminales en diferentes combinaciones. Veamos un ejemplo.

Supongamos que hay que describir una gramática simple para el cálculo de las expresiones en los cuales se puede aplicar sólo los números enteros y las operaciones más '+' y multiplicar '*'. Para simplificar, nos limitaremos con el caso cuando hay sólo dos operandos, por ejemplo, 10+1 o 5*6.

A base de esta tarea, primero, hay que determinar el terminal correspondiente al número entero. Precisamente este terminal va a compararse con cualquier operando válido en la expresión. Tomando en cuenta que el escáner produce el token correspondiente CONST_INTEGER para cada caso cuando él encuentra un número entero en el texto, definimos un objeto de la clase Terminal que hace referencia a este token. En el pseudocódigo, es:

Terminal value = CONST_INTEGER;

Esta entrada significa que hemos creado el objeto value de la clase Terminal que está asociado con el token «número entero».

Los signos de la operación también son terminales con los tokens correspondientes PLUS y STAR generados por el escáner para los signos aislados '+' y '*':

Terminal plus = PLUS;
Terminal star = STAR;

Para poder usar cualquiera de ellos en la expresión, introducimos un no terminal que combina dos operaciones tipo O:

NonTerminal operation = plus | star;

Aquí, entra en acción la sobrecarga de los operadores: en la clase Terminal (y todas las derivadas), operator| tiene que crear las referencias desde el objeto padre (en este caso, es operation) a los hijos (plus y star), y marcar el tipo de operación con ellos, es decir, OR lógico.

Cuando el analizador va a verificar el no terminal operation respecto a su correspondencia al texto debajo del cursor, él delega la siguiente verificación («en profundidad») al objeto operation, y éste llamará en el ciclo la verificación para los elementos derivados plus y star (hasta la primera coincidencia, puesto que es OR). Puesto que son terminales, devolverán sus tokens al analizador, y éste determinará si el carácter en el texto coincide con una de las operaciones.

La expresión puede componerse de varios valores y operaciones entre ellos. Por eso, la expresión también es un no terminal, y es necesario «abrirla» usando los terminales y no terminales.

NonTerminal expression = value + operation + value;

Aquí, sobrecargamos el operator+, el que significa que los operandos deben seguir uno a otro en orden especificada. Además, la implementación de la función supone que el no terminal padre expression debe guardar las referencias a los objetos hijos value, operation y otro value, y el tipo de operación es AND lógico. De hecho, en este caso, la regla se cumple sólo si todos los componentes están presentes.

La verificación del texto por el analizador respecto a la expresión correcta, primero, llamará la verificación en el array de las referencias expression, y luego, en los objetos value, operation (que usará de forma recursiva plus y minus), y finalmente, de nuevo en el objeto value. En cualquier etapa, si la verificación desciende hasta el nivel del terminal, el valor del token se compara con el carácter actual en el texto, y si hay una correspondencia, el cursor se mueve al siguiente token. De otro modo, hay que buscar una alternativa. Por ejemplo, en este caso, si la verificación de la operación plus falla, la verificación continuará con star. Si todas las opciones han sido agotadas sin encontrar ninguna correspondencia, entonces, las reglas de la gramática han sido violadas.

Los operadores '|' y '+' no son todos los operadores que vamos a sobrecargar en nuestras clases, pero vamos a dar su completa descripción en la sección de la implementación.

La declaración de los objetos de la clase Terminal y sus derivados que contienen las referencias a las unidades más y más pequeñas forma un árbol sintáctico abstracto (AST) de la gramática predefinida. Es abstracto por que no está asociado con ningún token concreto en el texto de entrada, es decir, teóricamente, la gramática describe un conjunto infinito de las cadenas válidas (en nuestro caso, los códigos MQL).

Pues bien, hemos considerado las clases principales del proyecto en términos generales. Para tener una visión general, las mostramos en el diagrama UML de las clases.


Diagrama UML de las clases del análisis sintáctico MQL

Diagrama UML de las clases del análisis sintáctico MQL

No hemos considerado algunas clases, como TreeNode. El analizador va a usar sus objetos en el proceso del análisis del texto de entrada, para guardar todas las correspondencias encontradas terminal=token. Como resultado, en la salida, obtenemos un árbol sintáctico concreto (CST), en el que todos los tokens están incluidos jerárquicamente en los terminales y no terminales de la gramática.

En principio, la creación del árbol será opcional, puesto que puede resultar ser grande para los códigos fuente reales. En vez de obtener los resultados del trabajo del analizador en forma del árbol, vamos a considerar una interfaz de la llamada de retorno (Callback). Una vez creado nuestro propio objeto que implementa esta interfaz, lo pasaremos al analizador y podremos recibir las notificaciones sobre cada «producto» concluido (regla gramatical disparada). De esta manera, podremos analizar la sintaxis y la semántica en tiempo real, sin esperar el árbol completo.

Las clases de los no terminales con el prefijo Hidden van a usarse para la creación implícita automática de los grupos intermedios de los objetos de la gramática, hablaremos detalladamente de eso en la siguiente sección.


Implementación

Lectura de los archivos

Source

La clase Source, en primer lugar, es un repositorio de una cadena con el texto procesado: Su base es la siguiente:

#define SOURCE_LENGTH 100000

class Source
{
  private:
    string source;

  public:
    Source(const uint length = SOURCE_LENGTH)
    {
      StringInit(source, length);
    }

    Source *operator+=(const string &x)
    {
      source += x;
      return &this;
    }

    Source *operator+=(const ushort x)
    {
      source += ShortToString(x);
      return &this;
    }
    
    ushort operator[](uint i) const
    {
      return source[i];
    }
    
    string get(uint start = 0, uint length = -1) const
    {
      return StringSubstr(source, start, length);
    }
    
    uint length() const
    {
      return StringLen(source);
    }
};

La clase tiene una variable source para el texto, así como, los operadores sobrepuestos para las operaciones más frecuentes con las cadenas. Por ahora pondremos aparte el segundo papel de esta clase que consiste en el soporte de la lista de archivos que se usan para montar la cadena almacenada. Teniendo este «envoltorio» para el texto de entrada, se puede intentar llenarlo desde un archivo. La clase FileReader se encarga de esta tarea.


FileReader

Antes de empezar a programar, es necesario determinar el modo de la apertura y lectura del archivo. Puesto que nosotros procesamos el texto, es lógico elegir el modo FILE_TXT. Eso nos librará del control manual de los caracteres del avance de líneas, que pueden ser codificados por diferentes editores de forma diferente (generalmente, es un par de caracteres, CR LF, pero en los códigos fuente MQL disponibles públicamente, también se encuentran alternativas, sólo CR o sólo LF). Recuerde que en el modo del texto, la lectura de los archivos se realiza línea por línea.

Otro problema a considerar es el soporte de los textos en diferentes codificaciones. Como queremos leer varios archivos diferentes, parte de los cuales puede guardarse como cadenas de un byte (ANSI), y otra parte, como cadenas de dos bytes (UNICODE), es deseable atribuir al propio sistema realizar la selección correcta en tiempo real (de archivo a archivo). Además, los archivos también pueden guardarse en la codificación UTF-8.

Como ha resultado, MQL sabe leer automáticamente los archivos de texto diferentes en la codificación correcta si establecemos los siguientes parámetros de entrada en la función FileOpen:

FileOpen(filename, FILE_READ | FILE_TXT | FILE_ANSI, 0, CP_UTF8);

En adelante, aplicaremos esta combinación, añadiendo, por defecto, las banderas FILE_SHARE_READ | FILE_SHARE_WRITE.

En la clase FileReader, vamos a prever los miembros para almacenar el nombre del archivo (filename), descriptor del archivo abierto (handle), línea de texto actual (line).

class FileReader
{
  protected:
    const string filename;
    int handle;
    string line;

Además, vamos a rastrear el número de la línea actual y la posición del cursor en la línea (columna).

    int linenumber;
    int cursor;

Vamos a guardar las líneas leídas en la instancia del objeto Source.

    Source *text;

En el constructor, se pasa el nombre del archivo, las banderas y el objeto hecho Source para recibir los datos.

  public:
    FileReader(const string _filename, Source *container = NULL, const int flags = FILE_READ | FILE_TXT | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE, const uint codepage = CP_UTF8): filename(_filename)
    {
      handle = FileOpen(filename, flags, 0, codepage);
      if(handle == INVALID_HANDLE)
      {
        Print("FileOpen failed ", _filename, " ", GetLastError());
      }
      line = NULL;
      cursor = 0;
      linenumber = 0;
      text = container;
    }

    string pathname() const
    {
      return filename;
    }

Comprobamos la apertura del archivo con éxito y cerramos el descriptor en el destructor.

    bool isOK()
    {
      return (handle > 0);
    }
    
    ~FileReader()
    {
      FileClose(handle);
    }

La lectura de los datos carácter por carácter desde el archivo se hace por el método getChar.

    ushort getChar(const bool autonextline = true)
    {
      if(cursor >= StringLen(line))
      {
        if(autonextline)
        {
          if(!scanLine()) return 0;
          cursor = 0;
        }
        else
        {
          return 0;
        }
      }
      return StringGetCharacter(line, cursor++);
    }

Cuando una cadena con el texto line está vacía o no ha sido leída hasta el final, este método intentará leer la siguiente cadena usando el método scanLine. Si la cadena line contiene los caracteres no procesados todavía, getChar simplemente devuelve el carácter debajo del cursor y mueve el cursor a la posición siguiente.

El método scanLine está definido de forma explícita:

    bool scanLine()
    {
      if(!FileIsEnding(handle))
      {
        line = FileReadString(handle);
        linenumber++;
        cursor = 0;
        if(text != NULL)
        {
          text += line;
          text += '\n';
        }
        return true;
      }
      
      return false;
    }

Obsérvese que, puesto que el archivo está abierto en modo de texto, los caracteres del avance de línea no se devuelven. No obstante, son necesarios para calcular las líneas, y como los indicios finales de algunas construcciones del lenguaje, por ejemplo, los comentarios de línea única. Por eso añadimos el símbolo '\n'.

Aparte de la lectura de datos desde el archivo, la clase FileReader tiene que proporcionar la posibilidad de comparar los datos de entrada debajo del cursor con lexemas. Para eso, añadimos los siguientes métodos.

    bool probe(const string lexeme) const
    {
      return StringFind(line, lexeme, cursor) == cursor;
    }

    bool match(const string lexeme) const
    {
      ushort c = StringGetCharacter(line, cursor + StringLen(lexeme));
      return probe(lexeme) && (c == ' ' || c == '\t' || c == 0);
    }
    
    bool consume(const string lexeme)
    {
      if(match(lexeme))
      {
        advance(StringLen(lexeme));
        return true;
      }
      return false;
    }

    void advance(const int next)
    {
      cursor += next;
      if(cursor > StringLen(line))
      {
        error(StringFormat("line is out of bounds [%d+%d]", cursor, next));
      }
    }

El método probe compara el texto con la lexema traspasada. El método match hace prácticamente lo mismo, pero también verifica que cada lexema esté mencionado como una palabra separada, es decir, después, tiene que ir un separador: espacio, tabulación o fin de línea. El método consume «absorbe» el lexema-palabra traspasado es decir, se cerciora de que el texto de entrada corresponda a lo establecido, y en caso del éxito, mueve el cursor al final del lexema. En caso del fallo, el cursor no se mueve, y el método devuelve false. El método advance simplemente mueve el cursor en adelante a una cantidad de los caracteres establecida.

Finalmente, vamos a considerar un pequeño método que devuelve el indicio del fin del archivo.

    bool isEOF()
    {
      return FileIsEnding(handle) && cursor >= StringLen(line);
    }

En la clase hay otros métodos auxiliares para leer los campos, se puede encontrarlos en los códigos fuente adjuntos.

Los objetos de la clase FileReader tienen que encontrarse en algún sitio. Delegamos este papel a la clase FileReaderController.

FileReaderController

En la clase FileReaderController, es necesario mantener la pila de los archivos de inclusión (includes), el mapa de los archivos ya incluidos (files), puntero al archivo actual procesado (current) y el texto de entrada (source).

class FileReaderController
{
  protected:
    Stack<FileReader *> includes;
    Map<string, FileReader *> files;
    FileReader *current;
    const int flags;
    const uint codepage;
    
    ushort lastChar;
    Source *source;

Las listas, pilas, arrays como BaseArray y los mapas Map encontrados en los códigos fuente están incluidos desde los archivos de cabecera auxiliares, que no vamos a describir aquí, ya que fueron usados en mis artículos anteriores. Pero el archivo completo se adjunta al artículo.

El controlador crea un objeto vacío source en su constructor:

  public:
    FileReaderController(const int _flags = FILE_READ | FILE_TXT | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE, const uint _codepage = CP_UTF8, const uint _length = SOURCE_LENGTH): flags(_flags), codepage(_codepage)
    {
      current = NULL;
      lastChar = 0;
      source = new Source(_length);
    }

La liberación de source y, de paso, de los objetos subordinados FileReader desde el mapa se realiza en el destructor:

#define CLEAR(P) if(CheckPointer(P) == POINTER_DYNAMIC) delete P;

    ~FileReaderController()
    {
      for(int i = 0; i < files.getSize(); i++)
      {
        CLEAR(files[i]);
      }
      delete source;
    }

Para insertar algún archivo en el procesamiento, inclusive el primer archivo del proyecto con la extensión mq5, introducimos el método include.

    bool include(const string _filename)
    {
      Print((current != NULL ? "Including " : "Processing "), _filename);
      
      if(files.containsKey(_filename)) return true;
      
      if(current != NULL)
      {
        includes.push(current);
      }
      
      current = new FileReader(_filename, source, flags, codepage);
      source.mark(source.length(), current.pathname());
      
      files.put(_filename, current);
      
      return current.isOK();
    }

Verifica el mapa files si el archivo especificado ya ha sido procesado, y devuelve enseguida true si el archivo existe. En caso contrario, el proceso continúa. Si es el primer archivo, creamos el objeto FileReader, hacemos que sea actual (current) y lo recordamos en el mapa files. Si no es el primera archivo, es decir, algún archivo ya se encuentra en el procesamiento, es necesario guardarlo previamente en la pila includes. Cuando el archivo de inclusión quede procesado por completo, volveremos al procesamiento del archivo actual desde el punto donde ha sido incluido el archivo de inclusión.

Una cadena en el método demostrado include por ahora no va a compilarse.

      source.mark(source.length(), current.pathname());

La clase source todavía no tiene el método mark. Como debe entenderse desde el contexto, en este lugar, alternamos desde un archivo a otro, y por eso, tenemos que marcar en algún lugar la fuente y su desplazamiento en el texto combinado. Precisamente de eso se encargará el método mark. En cualquier momento, la longitud actual del texto de entrada es el lugar donde serán adicionados los datos del nuevo archivo. Volvamos a la clase Source y añadimos el mapa de los archivos:

class Source
{
  private:
    Map<uint,string> files;

  public:
    void mark(const uint offset, const string file)
    {
      files.put(offset, file);
    }

La tarea principal de la lectura de los caracteres desde el archivo en la clase FileReaderController se ejecuta por el método getChar, que delega una parte del trabajo al objeto actual FileReader.

    ushort getChar(const bool autonextline = true)
    {
      if(current == NULL) return 0;
      
      if(!current.isEOF())
      {
        lastChar = current.getChar(autonextline);
        return lastChar;
      }
      else
      {
        while(includes.size() > 0)
        {
          current = includes.pop();
          source.mark(source.length(), current.pathname());
          if(!current.isEOF())
          {
            lastChar = current.getChar();
            return lastChar;
          }
        }
      }
      return 0;
    }

Si el archivo actual está presente y no ha sido leído hasta el final, llamamos a su método getChar y devolvemos el carácter resultante. Si el archivo actual ha sido leído hasta el final, verificamos si hay alguna directiva de inclusión de otros archivos en la pila includes. Si los archivos están presentes, extraemos el superior, hacemos que sea actual (current) y seguimos leyendo los caracteres desde él. Además de eso, no olvide marcar en el objeto source que la fuente de datos ha sido alternada al archivo antiguo.

La clase también sabe devolver el indicio de que la lectura ha llegado a su fin.

    bool isAtEnd()
    {
      return current == NULL || (current.isEOF() && includes.size() == 0);
    }

Entre otras cosas, proporcionamos un par de métodos para obtener el archivo y el texto actuales.

    const Source *text() const
    {
      return source;
    }
    
    FileReader *reader()
    {
      return current;
    }

Ahora, todo está listo para el procesamiento previo de los archivos por el preprocesador.


Preprocessor

El preprocesador va a gestionar la única instancia de la clase FileReaderController (controller), así como, la necesidad de cargar los archivos de cabecera (bandera loadIncludes):

class Preprocessor
{
  protected:
    FileReaderController *controller;
    const string includes;
    bool loadIncludes;

Es que podemos querer procesar algunos archivos sin dependencias, por ejemplo, para la depuración o para la reducción del tiempo de trabajo. En la variable de cadena includes, guardamos la carpeta que, por defecto, contiene los archivos de cabecera.

El constructor recibe todos estos valores, así como, el nombre del archivo inicial (junto con la ruta) de parte del usuario, crea el controlador y llama al método include para el archivo.

  public:
    Preprocessor(const string _filename, const string _includes, const bool _loadIncludes = false, const int _flags = FILE_READ | FILE_TXT | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE, const uint _codepage = CP_UTF8, const uint _length = SOURCE_LENGTH): includes(_includes)
    {
      controller = new FileReaderController(_flags, _codepage, _length);
      controller.include(_filename);
      loadIncludes = _loadIncludes;
    }

Ahora, escribiremos el método run que va a invocarse por el cliente directamente para el inicio del procesamiento de uno o varios archivos. 

    bool run()
    {
      while(!controller.isAtEnd())
      {
        if(!scanLexeme()) return false;
      }
      return true;
    }

Leemos las lecturas hasta que el controlador llegue al fin de los datos.

Este es el método scanLexeme:

    bool scanLexeme()
    {
      ushort c = controller.getChar();
      
      switch(c)
      {
        case '#':
          if(controller.reader().consume("include"))
          {
            if(!include())
            {
              controller.reader().error("bad include");
              return false;
            }
          }
          break;
          ...
      }
      return true; // symbol consumed
    }

Si el programa ve el símbolo '#', intenta «tragar» la siguiente palabra "include". Si no hay, entonces, se trata de un carácter '#' único, y simplemente se omite (getChar mueve el cursor hacia adelante a una posición). Si se encuentra la palabra "include", hay que procesar la directiva, de eso se encarga el método include.

    bool include()
    {
      ushort c = skipWhitespace();
      
      if(c == '"' || c == '<')
      {
        ushort q = c;
        if(q == '<') q = '>';
        
        int start = controller.reader().column();
        
        do
        {
          c = controller.getChar();
        }
        while(c != q && c != 0);
        
        if(c == q)
        {
          if(loadIncludes)
          {
            Print(controller.reader().source());

            int stop = controller.reader().column();
  
            string name = StringSubstr(controller.reader().source(), start, stop - start - 1);
            string path = "";
  
            if(q == '"')
            {
              path = controller.reader().pathname();
              StringReplace(path, "\\", "/");
              string parts[];
              int n = StringSplit(path, '/', parts);
              if(n > 0)
              {
                ArrayResize(parts, n - 1);
              }
              else
              {
                Print("Path is empty: ", path);
                return false;
              }
              
              int upfolder = 0;
              while(StringFind(name, "../") == 0)
              {
                name = StringSubstr(name, 3);
                upfolder++;
              }
              
              if(upfolder > 0 && upfolder < ArraySize(parts))
              {
                ArrayResize(parts, ArraySize(parts) - upfolder);
              }
              
              path = StringImplodeExt(parts, CharToString('/')) + "/";
            }
            else // '<' '>'
            {
              path = includes; // folder;
            }
            
            return controller.include(path + name);
          }
          else
          {
            return true;
          }
        }
        else
        {
          Print("Incomplete include");
        }
      }
      return false;
    }

Este método ignora todos los espacios después de la palabra "include" usando skipWhitespace (aquí, no se considera), encuentra el paréntesis de apertura o el carácter '<', luego, escanea el texto hasta el paréntesis pareado o el carácter de cierre '>', y finalmente, selecciona la cadena con la ruta y el nombre del archivo de cabecera. Luego, se realiza el procesamiento de las opciones del ascenso adicional del archivo desde la misma carpeta o desde la carpeta estándar de los archivos de cabecera. Como resultado, se forma un nuevo archivo y el nombre para la carga, después de eso, el controlador recibe el encargo de procesar el archivo.

Aparte del procesamiento de la directiva #include, necesitamos ignorar los bloques de los comentarios para no interpretarlos como instrucciones si la lexema "#include" estuviera dentro. Por eso, añadimos las opciones correspondientes al operador switch, en el método scanLexeme.

        case '/':
          if(controller.reader().probe("*"))
          {
            controller.reader().advance(1);
            if(!blockcomment())
            {
              controller.reader().error("bad block comment");
              return false;
            }
          }
          else
          if(controller.reader().probe("/"))
          {
            controller.reader().advance(1);
            linecomment();
          }
          break;
        case '"':
          if(!literal())
          {
            controller.reader().error("unterminated string");
            return false;
          }
          break;

Aquí teneos el ejemplo que demuestra cómo se omiten los comentarios en bloque:

    bool blockcomment()
    {
      ushort c = 0, c_;
      
      do
      {
        c_ = c;
        c = controller.getChar();
        if(c == '/' && c_ == '*') return true;
      }
      while(!controller.reader().isEOF());
      
      return false;
    }

Otros métodos auxiliares están implementados de la manera semejante.

Pues bien, teniendo la clase Preprocessor y otras clases, teóricamente ya podemos cargar el texto desde los archivos de trabajo aproximadamente así.

#property script_show_inputs

input string SourceFile = "filename.txt";
input string IncludesFolder = "";
input bool LoadIncludes = false;

void OnStart()
{
  Preprocessor loader(SourceFile, IncludesFolder, LoadIncludes);
  
  if(!loader.run())
  {
    Print("Loader failed");
    return;
  }

  // output entire data as it is assembled from one or many files
  int handle = FileOpen("dump.txt", FILE_WRITE | FILE_TXT | FILE_ANSI, 0, CP_UTF8);
  FileWriteString(handle, loader.text().get());
  FileClose(handle);
}

¿Y por qué «teóricamente»? Es que MetaTrader nos permite sólo con los archivos en el entorno protegido (sandbox), la carpeta MQL5/Files. Sin embargo, nuestro objetivo es el procesamiento de los códigos fuente que se encuentran en las carpetas MQL5/Include, MQL5/Scripts, MQL5/Experts, MQL5/Indicators.

Para evitar esta limitación, usaremos la posibilidad de Windows de atribuir las referencias simbólicas a los objetos del sistema de archivos. En nuestro caso, para redireccionar el acceso a las carpetas en el ordenador local, es mejor usar las llamadas uniones (junction). Se crean a usando el comando:

mklink /J new_name existing_target

El parámetro new_name representa el nombre de la nueva «carpeta» virtual que va a indicar en la carpeta real existing_target.

Para crear las uniones con las carpetas especificadas con los códigos fuente, abrimos la carpeta MQL5/Files, creamos dentro la subcarpeta Sources y entramos en ella. Luego, copiamos el archivo makelink.bat adjunto al artículo dentro de esta carpeta. Este script de comando contiene prácticamente una sola cadena:

mklink /J %1 "..\..\%1\"

Recibe un parámetro de entrada %1, es decir, el nombre de la carpeta aplicada entre las carpetas que figuran dentro de MQL5 (por ejemplo, "Include"). La ruta relativa "..\..\" supone que el archivo de comando se encuentra en la carpeta especificada MQL5/Files/Sources, entonces, la carpeta de destino (existing_target) será formada como MQL5/%1. Por ejemplo, si estando en la carpeta Sources, ejecutamos el comando

makelink Include

en la carpeta Sources, aparece la carpeta virtual Include, entrando en la cual, pasamos a MQL5/Include. Por analogía, se puede crear los «gemelos» para las carpetas Experts, Scripts, etc. En la imagen de abajo, se muestra el Explorador con la carpeta abierta MQL5/Include/Expert con los archivos de cabecera estándar disponibles dentro de la carpeta MQL5/Files/Sources.

Referencias simbólicas de Windows para las carpetas de los códigos fuente MQL5

Referencias simbólicas de Windows para las carpetas de los códigos fuente MQL5

Si hace falta, se puede eliminar las referencias simbólicas como los archivos comunes (pero está claro que antes hay que cerciorase de que se elimina la carpeta con una flecha en la esquina inferior izquierda, es decir, la referencia, y no la carpeta original).

Sería posible crear la conexión directamente con la carpeta raíz de MQL5, pero yo prefiero y recomiendo abrir el acceso por token: así todos los programas MQL podrán leer sus códigos fuente por referencia, incluyendo los logins, contraseñas y los sistemas comerciales super secretos, si están almacenados ahí.
Después de crear las referencias, el parámetro IncludesFolder del script de arriba realmente va a funcionar: el valor "Sources/Include/" indica en la carpeta real MQL5/Include. En el parámetro SourceFile, se puede definir para el análisis, por ejemplo, el código fuente de algún script "Sources/Scripts/test.mq5".

División en tokens

Los tipos de los tokens que deben ser diferenciados en MQL están resumidos en una única enumeración TokenType, en el archivo de cabecera con el mismo nombre (se adjunta). No vamos a hablar de él en este artículo. Obsérvese que hay tokens de un único carácter, por ejemplo, como diferentes tipo de corchetes ('(', '[', '{'), signo igual '=', más '+' o menos '-', así como, los tokens de dos caracteres, por ejemplo, '==', '!=', etc. Además de eso, como tokens individuales serán los números, cadenas, fechas (es decir, constantes de tipos soportados), todas las palabras reservadas en MQL (operadores, tipos, this, modificadores como input, const, etc.), así como, los identificadores (otras palabras). Adicionalmente, hay tokens EOF para marcar el final de los datos de entrada.


Token

Al visualizar el texto, el escáner va a determinar el tipo de cada siguiente token usando un algoritmo especial (se considera más abajo) y crear un objeto de la clase Token. Es una clase muy simple.

class CWndContainer
{
  private:
    TokenType type;
    int line;
    int offset;
    int length;

  public:
    Token(const TokenType _type, const int _line, const int _offset, const int _length = 0)
    {
      type = _type;
      line = _line;
      offset = _offset;
      length = _length;
    }
    
    TokenType getType() const
    {
      return type;
    }
    
    int getLine() const
    {
      return line;
    }
    ...

    string content(const Source *source) const
    {
      return source.get(offset, length);
    }
};

El objeto almacena el tipo del token, su desplazamiento en el texto y la longitud. Si es necesario obtener el valor del token, pasamos el puntero a la cadena source al método content, y recortamos el fragmento correspondiente de esta cadena.

Ahora ha llegado el momento para recurrir al escáner (llamado también «tokenizer», a la inglés).


Scanner (tokenizer)

En la clase Scanner, describimos el array estático con palabras clave MQL:

class Scanner
{
  private:
    static string reserved[];

y luego, lo inicializamos en el código fuente, usando la inclusión del archivo de texto:

static string Scanner::reserved[] =
{
#include "reserved.txt"
};

Completamos este array con un mapa estático de correspondencia entre las representaciones de cadena y el tipo de cada token.

    static Map<string, TokenType> keywords;

Llenamos el mapa en el constructor (mostrado más abajo).

En el escáner, también vamos a necesitar un puntero a los datos de entrada, lista de tokens resultante, así como, varios contadores.

    const Source *source; // wrapped string
    List<Token *> *tokens;
    int start;
    int current;
    int line;

La variable start siempre va a indicar en el inicio del siguiente token a ser procesado. La variable current es el cursor del desplazamiento por el texto. Siempre va a «huir» de start hacia adelante (a la medida de que se verifica que los caracteres actuales correspondan a algún token), y en cuanto la correspondencia se encuentre, la subcadena de start a current entra en el token nuevo. La variable line es el número de la línea actual en el texto general.

Constructor de la clase Scanner:

  public:
    Scanner(const Source *_source): line(0), current(0)
    {
      tokens = new List<Token *>();
      if(keywords.getSize() == 0)
      {
        for(int i = 0; i < ArraySize(reserved); i++)
        {
          keywords.put(reserved[i], TokenType(BREAK + i));
        }
      }
      source = _source;
    }

Aquí, BREAK es el identificador del tipo del token para la primera palabra reservada en orden alfabético. El orden de las cadenas en el archivo reserved.txt y de los identificadores en la enumeración TokenType tiene que ser el mismo. Por ejemplo, al elemento BREAK en la enumeración le corresponde, obviamente, "break".

En la clase, el método scanTokens es fundamental.

    List<Token *> *scanTokens()
    {
      while(!isAtEnd())
      {
        // We are at the beginning of the next lexeme
        start = current;
        scanToken();
      }
  
      start = current;
      addToken(EOF);
      return tokens;
    }

En su ciclo, se generan nuevos tokens hasta que se alcance el final del texto. Los métodos isAtEnd y addToken son simples:

    bool isAtEnd() const
    {
      return (uint)current >= source.length();
    }

    void addToken(TokenType type)
    {
      tokens.add(new Token(type, line, start, current - start));
    }

El método scanToken nos sirve «de estropajo», pero antes de presentarlo, hay que conocer algunos métodos auxiliares simples, que parecen mucho a los que ya hemos visto en la clase Preprocessor, por eso, probablemente, no sea necesario explicar su función.

    bool match(ushort expected)
    {
      if(isAtEnd()) return false;
      if(source[current] != expected) return false;
  
      current++;
      return true;
    }
    
    ushort previous() const
    {
      if(current > 0) return source[current - 1];
      return 0;
    }
    
    ushort peek() const
    {
      if(isAtEnd()) return '\0';
      return source[current];
    }
    
    ushort peekNext() const
    {
      if((uint)(current + 1) >= source.length()) return '\0';
      return source[current + 1];
    }

    ushort advance()
    {
      current++;
      return source[current - 1];
    }

Ahora, volvemos al método scanToken.

    void scanToken()
    {
      ushort c = advance();
      switch(c)
      {
        case '(': addToken(LEFT_PAREN); break;
        case ')': addToken(RIGHT_PAREN); break;
        ...

Lee el siguiente carácter y, dependiendo de su código, crea un token. No vamos a mostrar todos los tokens de un carácter, ya que su creación es semejante.

Si el token admite dos caracteres, el procesamiento se hace más complicado:

        case '-': addToken(match('-') ? DEC : (match('=') ? MINUS_EQUAL : MINUS)); break;
        case '+': addToken(match('+') ? INC : (match('=') ? PLUS_EQUAL : PLUS)); break;
        ...

Aquí, se muestra la formaión de los tokens para los lexemas '--', '-=', '++', '+='.

La versión actual del escáner omite los comentarios:

        case '/':
          if(match('/'))
          {
            // A comment goes until the end of the line
            while(peek() != '\n' && !isAtEnd()) advance();
          }

Los interesados pueden guardarlos en unos tokens especiales.

Las construcciones de bloques, como las cadenas, literales, directivas del preprocesador, se procesan en los métodos auxiliares separados, no vamos a considerarlas en detalle:

        case '"': _string(); break;
        case '\'': literal(); break;
        case '#': preprocessor(); break;

Así, se escanea una cadena:

    void _string()
    {
      while(!(peek() == '"' && previous() != '\\') && !isAtEnd())
      {
        if(peek() == '\n')
        {
          line++;
        }
        advance();
      }
  
      if(isAtEnd())
      {
        error("Unterminated string");
        return;
      }
  
      advance(); // The closing "
  
      addToken(CONST_STRING);
    }

Cuando ninguno de los tipos de los tokens ha sido activado, se realiza una verificación predefinida que incluye los números, identificadores y palabras reservadas.

        default:
        
          if(isDigit(c))
          {
            number();
          }
          else if(isAlpha(c))
          {
            identifier();
          }
          else
          {
            error("Unexpected character `" + ShortToString(c) + "` 0x" + StringFormat("%X", c) + " @ " + (string)current + ":" + source.get(MathMax(current - 10, 0), 20));
          }
          break;

Las implementaciones de isDigit, isAlpha son obvias. Aquí, mencionaremos sólo el método identifier.

    void identifier()
    {
      while(isAlphaNumeric(peek())) advance();

      // See if the identifier is a reserved word
      string text = source.get(start, current - start);
  
      TokenType type = keywords.get(text);
      if(type == null) type = IDENTIFIER;
      
      addToken(type);
    }

Las implementaciones completas de todos los métodos se encuentran en los códigos fuente adjuntos. Para no inventar la rueda, presté una parte del código del libro Crafting Interpreters, aunque claro que tenía que hacer algunas modificaciones.

Pues, éste es todo el escáner. Si no hay errores, el método scanTokens devolverá al usuario una lista de tokens que puede ser pasada al analizador. Sin embargo, el analizador sintáctico tiene que tener una gramática para analizar la lista de los tokens. Así, antes de continuar con el analizador, hay que considerar la descripción de la gramática. Ella se genera de los los objetos de la clase Terminal y de sus derivados.

Descripción de la gramática

Primero, imaginemos que, en vez de la gramática MQL, es necesario describir la gramática de un cierto lenguaje simple para calcular las expresiones aritméticas, una calculadora. Es una fórmula válida para el calculo:

(10 + 1) * 2

Vamos a permitir sólo los números enteros y las operaciones '+', '-', '*', '/', sin dar precedencia, es decir, para las prioridades, vamos a usar los paréntesis '(' ')'.

Como punto de entrada de la gramática, será un no terminal que describe la expresión entera. Supongamos que, para eso, bastará con escribir:

NonTerminal expression;

La expresión se compone de los operandos (valores enteros) y símbolos de operaciones. Todos ellos son terminales, es decir, pueden crearse a base de los tokens soportados por el escáner.

Supongamos que se describen así:

Terminal plus(PLUS), star(STAR), minus(MINUS), slash(SLASH);
Terminal value(CONST_INTEGER);

Como podemos observar, el constructor de los terminales debe permitir el traspaso del tipo del token como parámetro.

La expresión más simple que puede existir es un número simple. Sería lógico expresarlo así:

expression = value;

Es una sobrecarga del operador de asignación. Dentro de ella, tenemos que guardar la referencia al objeto value (le damos el nombre eq, de la palabra equivalence) dentro de expression, en alguna variable. Cuando el analizador reciba la tarea de verificar la correspondencia de expression a la cadena insertada, él delega la verificación al no terminal. Éste, a su vez, «verá» la referencia a value, solicitará que el analizador compruebe value, y la verificación, finalmente, llegará al terminal, donde se realizará la comparación de los tokens (token en el terminal con token en el flujo de entrada).

No obstante, la expresión puede tener adicionalmente una operación y el segundo operando, por tanto, es necesario extender la regla expression. Para eso, primero, escribiremos un nuevo no terminal:

NonTerminal operation;
operation = (plus | star | minus | slash) + value;

Aquí, ocurren muchas cosas interesantes al fondo. El operador '|' debe ser sobrecargado en la clase con el fin de asegurar la combinación de los elementos en un grupo lógico o según OR lógico. No obstante, el operados se invoca en el terminal, un carácter simple, pero nosotros necesitamos un grupo de elementos. Por eso, el primer elemento del grupo, para el que el entorno de ejecución llamará al operador (en este caso, es plus), tiene que comprobar si es un miembro de algún grupo, y si el grupo todavía no existe, crearlo dinámicamente en forma de un objeto de la clase HiddenNonTerminalOR. Luego, la implementación del operador sobrecargado debe incluir this y el terminal derecho adyacente star (pasado como un argumento en la función-operador) en el grupo recién creado. El operador devuelve una referencia a este grupo, para que los siguientes operadores '|' (en forma de cadena) ya sean llamados para HiddenNonTerminalOR.

Para soportar el array con los miembros del grupo, consideramos el array next en la clase. Su nombre significa el siguiente nivel de la especificación de los elementos de la gramática. Para cada elemento introducido en este array de nodos derivados, es necesario definir una referencia opuesta al nodo padre (lo llamaremos parent). La presencia de un parent diferente a cero indicará en su asociación al grupo. Como resultado de la ejecución del código presentado dentro de los paréntesis, obtenemos un HiddenNonTerminalOR con el array que contiene todos los 4 símbolos de las operaciones.

Luego, el operador sobrecargado '+' entra en el juego. Tiene que funcionar de manera semejante como el operador '|', es decir, también debe crear un grupo implícito de los elementos, pero esta vez, de la clase HiddenNonTerminalAND, y durante el análisis, ellos deben ser verificados según la regla AND.

Obsérvese que se genera una jerarquía subordinada de los terminales y no terminales. En este caso, el objeto HiddenNonTerminalAND va a contener dos elementos derivados: grupo HiddenNonTerminalOR recién creado y value. HiddenNonTerminalAND, a su vez, está subordinado al no terminal operation.

La prioridad de las operaciones '|' y '+' es tal que, a falta de los paréntesis, AND se procesa primero, y OR se procesa después. Precisamente por eso, en operation, tuvimos que poner todas las variantes de caracteres entre paréntesis.

Teniendo la definición del no terminal operation, podemos corregir la gramática de la expresión:

expression = value + operation;

Parece que describe las expresiones tipo A @ B, donde A y B son números enteros, y @ es una acción. Pero en este caso, hay una «trampa».

Ya tenemos dos reglas donde participa el objeto value. Eso quiere decir que la referencia al padre establecida en la primera regla será reescrita en la segunda. Para evitar eso, es necesario insertar en las reglas las copias a los objetos, en vez de los propios objetos.

Para eso, vamos a prever la sobrecarga de dos operadores: '~' y '^'. El primero de ellos es unario y se coloca antes del operando. En el objeto que ha recibido la llamada de la función-operador correspondiente, vamos a crear dinámicamente un objeto intermedio y devolverlo al código de la llamada. El segundo operador es binario. Además del objeto, le pasamos el número actual de la cadena en el código fuente de la gramática, es decir, una constante __LINE__ predefinida por el compilador MQL. De esta manera, conseguimos distinguir las instancias descritas implícitamente de los objetos según el número de las cadenas donde se crean. Eso ayudará en la depuración de las gramáticas complejas. En otras palabras, los operadores '~' y '^' ejecutan el mismo trabajo, pero el primero lo realiza en el modo del lanzamiento, y el segundo, en el modo de la depuración.

Las copias intermedias representan un objeto de la clase HiddenNonTerminal, en el cual la variable eq mencionada antes se refiere al objeto original.

Pues, vamos a reescribir la gramática de las expresiones tomando en cuenta la creación de los objetos intermedios.

operation = (plus | star | minus | slash) + ~value;
expression = ~value + operation;

Puesto que operation se usa sólo una vez, se puede no crear un gemelo para él. Cada referencia lógica aumenta la recursión a una unidad cuando se abre la expresión. No obstante, con el fin de evitar los errores en las gramáticas grandes, se recomienda hacer las referencias en todas las partes. Incluso si, por ahora, algún no no terminal se usa sólo una vez, puede que aparezca posteriormente en otra parte de la gramática. En el código fuente, vamos a prever una verificación de la redefinición del nodo padre para generar un aviso sobre el error.

Ahora, nustra gramática es capaz de procesar "10+1", pero ha perdido la posibilidad de leer un número individual. De hecho, el no terminal operation tiene que ser opcional. Para este propósito, implementamos la sobrecarga del operador '*'. Si el elemento de la gramática se multiplica por 0, entonces, se puede omitirlo durante la verificación (su ausencia no provoca ningún error).

expression = ~value + operation*0;

La sobrecarga del operador de multiplicación permite implementar otra cosa importante, es decir, la repetición del elemento un número aleatorio de veces. En este caso, vamos a multiplicar el elemento por 1. En la clase del terminal, esta propiedad (multiplicidad o opcionalidad) se almacena en la variable mult. Los casos cuando un cierto elemento es opcional y puede repetirse muchas veces se implementa fácilmente usando dos referencias: la primera tiene que ser opcional (optional*0), y la segunda, múltiple (optional = element*1).

En la gramática actual del calculador, hay otro fallo. No sirve para las expresiones largas con varias operaciones, como 1+2+3+4+5. Para corregir este fallo, hay que alterar el no terminal operation.

operation = (plus | star | minus | slash) + ~expression;

Hemos sustituido value por expression, ampliando así el análisis cíclico de las nuevas partes finales de las expresiones.

No queda el último toque, es decir el soporte de las expresiones entre paréntesis. Como no es difícil adivinar, desempeñan el mismo papel que el valor único value. Por eso, lo redifinimos como una alternativa entre dos opciones: un número entero o subexpresión entre paréntesis. Es la gramática completa:

NonTerminal expression;
NonTerminal value;
NonTerminal operation;

Terminal number(CONST_INTEGER);
Terminal left(LEFT_PAREN);
Terminal right(RIGHT_PAREN);
Terminal plus(PLUS), star(STAR), minus(MINUS), slash(SLASH);

value = number | left + expression^__LINE__ + right;
operation = (plus | star | minus | slash) + expression^__LINE__;
expression = value + operation*0;

Vamos a considerar en detalles la estructura de las clases especificadas.


Terminal

En la clase Terminal, describimos los campos tipo token (me), las propiedades de la multiplicidad (mult), opcionalidad del nombre (name, para identificar los no terminales en logs), referencias al producto (eq), al padre (parent) y los elementos subordinados (array next).

class Terminal
{
  protected:
    TokenType me;
    int mult;
    string name;
    Terminal *eq;
    BaseArray<Terminal *> next;
    Terminal *parent;

Los campos se rellenan en los constructores, así como, en los métodos setter, y se leen usando los métodos getter, que se omiten aquí por motivos de brevedad.

La sobrecarga de los operadores va a realizarse según el siguiente principio:

    virtual Terminal *operator|(Terminal &t)
    {
      Terminal *p = &t;
      if(dynamic_cast<HiddenNonTerminalOR *>(p.parent) != NULL)
      {
        p = p.parent;
      }

      if(dynamic_cast<HiddenNonTerminalOR *>(parent) != NULL)
      {
        parent.next << p;
        p.setParent(parent);
      }
      else
      {
        if(parent != NULL)
        {
          Print("Bad OR parent: ", parent.toString(), " in ", toString());

          ... error
        }
        else
        {
          parent = new HiddenNonTerminalOR("hiddenOR");

          p.setParent(parent);
          parent.next << &this;
          parent.next << p;
        }
      }
      return parent;
    }

Aquí, se muestra el agrupamiento por OR. Con AND pasa lo mismo.

La definición del indicio de la multiplicidad en el operador '*':

    virtual Terminal *operator*(const int times)
    {
      mult = times;
      return &this;
    }

Usando el destructor, nos encargaremos de una correcta exclusión de las instancias creadas dinámicamente.

    ~Terminal()
    {
      Terminal *p = dynamic_cast<HiddenNonTerminal *>(parent);
      while(CheckPointer(p) != POINTER_INVALID)
      {
        Terminal *d = p;
        if(CheckPointer(p.parent) == POINTER_DYNAMIC)
        {
          p = dynamic_cast<HiddenNonTerminal *>(p.parent);
        }
        else
        {
          p = NULL;
        }
        CLEAR(d);
      }
    }

Y, finalmente, es el método principal de la clase Terminal responsable del análisis sintáctico.

    virtual bool parse(Parser *parser)
    {
      Token *token = parser.getToken();

      bool eqResult = true;

Aquí, obtenemos una referencia al analizador, leemos el token actual en él (la clase del analizador será analizado a continuación).

      if(token.getType() == EOF && mult == 0) return true;

Si el token es EOF, y el elemento actual es opcional, entonces, ha sido detectado el final correcto del texto.

Luego, comprobamos si hay una referencia desde el operador sobrecargado '=' a la instancia original del elemento, si nos encontramos en la copia. Si hay una referencia, la mandamos al método match para verificarla con el analizador.

      if(eq != NULL) // redirect
      {
        eqResult = parser.match(eq);
        
        bool lastResult = eqResult;
        
        // if multiple tokens expected and while next tokens are successfully consumed
        while(eqResult && eq.mult == 1 && parser.getToken() != token && parser.getToken().getType() != EOF)
        {
          token = parser.getToken();
          eqResult = parser.match(eq);
        }
        
        eqResult = lastResult || (mult == 0);
        
        return eqResult; // redirect was fulfilled
      }

Además, aquí se procesa la situación cuando ele elemento puede repetirse (mult = 1): el analizador se invoca repetidamente hasta que el método match devuelve el éxito.

El indicio del éxito (true o false) se devuelve desde el método parse en este segmento, y en otras situaciones, por ejemplo, para el terminal:

      if(token.getType() == me) // token match
      {
        parser.advance(parent);
        return true;
      }

Para el terminal, simplemente comparamos su toquen me con el toquen actual en los datos de entrada. Si hay una correspondencia, usamos el método advance para que el analizador mueva el cursor al siguiente token. En el mismo método, el analizador informa al programa del cliente de que ha tenido lugar un producto en el no terminal parent.

Para un grupo de elementos, todo es algo más complicado. Vamos a considerar el caso de AND lógico, la variante para OR será parecida. Usando el método virtual hasAnd (en la clase Terminal, devuelve false, mientras que en los herederos, está bloqueado) determinamos si el array con los elementos subordinados está bloqueado para la verificación por AND.

      else
      if(hasAnd()) // check AND-ed conditions
      {
        parser.pushState();
        for(int i = 0; i < getNext().size(); i++)
        {
          if(!parser.match(getNext()[i]))
          {
            if(mult == 0)
            {
              parser.popState();
              return true;
            }
            else
            {
              parser.popState();
              return false;
            }
          }
        }

        parser.commitState(parent);
        return true;
      }

Puesto que este no terminal será considerado como correcto si sus componentes coinciden con la gramática, llamamos al método match del analizador para todos ellos en el ciclo. Si ocurre por lo menos un resultado negativo, la verificación entera falla. No obstante, hay una excepción: si el no terminal es opcional, las reglas de la gramática igualmente serán satisfechas incluso en caso de la devolución de false desde el método match.

Obsérvese que, antes del ciclo, guardamos el estado actual (pushState) en el analizador, lo restauramos (popState) en caso de la salida anticipada, y confirmamos el nuevo estado con una verificación exitosa totalmente concluida (commitState). Eso es necesario para aplazar las notificaciones para el código cliente sobre un nuevo «product» hasta el momento cuando toda la regla de la gramática se active enteramente. En realidad, la palabra «estado» oculta simplemente la posición actual del cursor en el flujo de los tokens de entrada.

Si ni los tokens ni los grupos de los elementos subordinados funcionan dentro del método parse, sólo nos queda comprobar la opcionalidad del objeto actual:

      else
      if(mult == 0) // last chance
      {
        // parser.advance(); - don't consume token and proceed to next production
        return true;
      }

En caso contrario, fallamos al final del método, lo que indica en un error (el texto no corresponde a la gramática).

      if(dynamic_cast<HiddenNonTerminal *>(&this) == NULL)
      {
        parser.trackError(&this);
      }
      
      return false;
    }

Ahora, vamos a describir las clases heredadas de la clase Terminal.



No terminales - ocultos y explícitos

La principal tarea de la clase HiddenNonTerminal consiste en crear las copias dinámicas de los objetos y limpiar la basura.

class HiddenNonTerminal: public Terminal
{
  private:
    static List<Terminal *> dynamic; // garbage collector

  public:
    HiddenNonTerminal(const string id = NULL): Terminal(id)
    {
    }

    HiddenNonTerminal(HiddenNonTerminal &ref)
    {
      eq = &ref;
    }

    virtual HiddenNonTerminal *operator~()
    {
      HiddenNonTerminal *p = new HiddenNonTerminal(this);
      dynamic.add(p);
      return p;
    }
    ...
};

La clase HiddenNonTerminalOR asegura la sobrecarga del operador '|' (más simple que en la clase Terminal, porque HiddenNonTerminalOR es un «contenedor» en sí, siendo el propietario del grupo de los elementos subordinados de la gramática).

class HiddenNonTerminalOR: public HiddenNonTerminal
{
  public:
    virtual Terminal *operator|(Terminal &t) override
    {
      Terminal *p = &t;
      next << p;
      p.setParent(&this);
      return &this;
    }
    ...
};

La clase HiddenNonTerminalAND está implementado de forma semejante.

La clase NonTerminal asegura la sobrecarga del operador '=' («producto» en la reglas).

class NonTerminal: public HiddenNonTerminal
{
  public:
    NonTerminal(const string id = NULL): HiddenNonTerminal(id)
    {
    }

    virtual Terminal *operator=(Terminal &t)
    {
      Terminal *p = &t;
      while(p.getParent() != NULL)
      {
        p = p.getParent();
        if(p == &t)
        {
          Print("Cyclic dependency in assignment: ", toString(), " <<== ", t.toString());
          p = &t;
          break;
        }
      }
    
      if(dynamic_cast<HiddenNonTerminal *>(p) != NULL)
      {
        eq = p;
      }
      else
      {
        eq = &t;
      }
      eq.setParent(this);
      return &this;
    }
};

Finalmente, existe la clase Rule (heredero de NonTerminal), pero su papel consiste en la descripción de la gramática marcando algunas reglas como básicas (si generan un objeto Rule) o las reglas secundarias (si su resultado es NonTerminal).

Para facilitar la descripción de los no terminales, han sido creadas las macro:

// debug
#define R(Y) (Y^__LINE__)

// release
#define R(Y) (~Y)

#define _DECLARE(Cls) NonTerminal Cls(#Cls); Cls
#define DECLARE(Cls) Rule Cls(#Cls); Cls
#define _FORWARD(Cls) NonTerminal Cls(#Cls);
#define FORWARD(Cls) Rule Cls(#Cls);

Como argumento de las macro, se especifica una cadena (un nombre exclusivo). La declaración «forward» será necesaria cuando los no terminales se refieren uno a otro (lo hemos visto en la gramática de la calculadora).

Además de eso, para generar los terminales con tokens, tenemos implementado una clase especial Keywords con el soporte de la recolección de la basura.

class CWndContainer
{
  private:
    static List<Terminal *> keywords;

  public:
    static Terminal *get(const TokenType t, const string value = NULL)
    {
      Terminal *p = new Terminal(t, value);
      keywords.add(p);
      return p;
    }
};

Para su uso en la descripción de la gramática, existen sus macro:

#define T(X) Keywords::get(X)
#define TC(X,Y) Keywords::get(X,Y)

Veamos como la gramática de la calculadora considerada anteriormente se describe usando las interfaces de programación implementadas.

  FORWARD(expression);
  _DECLARE(value) = T(CONST_INTEGER) | T(LEFT_PAREN) + R(expression) + T(RIGHT_PAREN);
  _DECLARE(operation) = (T(PLUS) | T(STAR) | T(MINUS) | T(SLASH)) + R(expression);
  expression = R(value) + R(operation)*0;

Finalmente, estamos listos para aprender la clase Parser.


Parser

La clase Parser tiene los miembros para almacenar la lista de entrada de los tokens (tokens), la posición actual en ella (cursor), la posición máxima conocida (maxcursor, se usa para diagnosticar los errores), la pila de las posiciones entes de las llamada a los grupos incorporados de los elementos (states, para el retroceso, acordamos de backtracking) y las referencias al texto de entrada (source, para la impresión de los logs y otros fines).

class Parser
{
  private:
    BaseArray<Token *> *tokens; // input stream
    int cursor;                 // current token
    int maxcursor;
    BaseArray<int> states;
    const Source *source;

Además de eso, el analizador rastrea toda la jerarquía de las llamada por los elementos de la gramática usando la pila stack. La clase TreeNode aplicada en esta plantilla es un simple contenedor para un par (terminal, token), se puede ver su código fuente en el archivo adjunto. Los errores se acumulan para el diagnóstico en otra pila (errors).

    // current stack, how the grammar unwinds
    Stack<TreeNode *> stack;

    // holds current stack on every problematic point
    Stack<Stack<TreeNode *> *> errors;

El constructor del analizador acepta una lista de los tokens, el texto de origen y la bandera opcional para habilitar la construcción del árbol sintáctico en el proceso del análisis.

  public:
    Parser(BaseArray<Token *> *_tokens, const Source *text, const bool _buildTree = false)

Cuando el modo del árbol está habilitado, todos los «productos» de éxito, que se encuentran en la pila en forma de objetos TreeNode, se enhilan en la raíz del árbol (variable tree):

    TreeNode *tree;   // concrete syntax tree (optional result)

Para este propósito, la clase TreeNode soporta el array de los nodos derivados. Después de que el analizador termine su trabajo, el árbol (si ha sido activado) puede ser obtenido a través del método:

    const TreeNode *getCST() const
    {
      return tree;
    }

El método principal del analizador (match) de forma simplificada es el siguiente.

    bool match(Terminal *p)
    {
      TreeNode *node = new TreeNode(p, getToken());
      stack.push(node);
      int previous = cursor;
      bool result = p.parse(&this);
      stack.pop();
      
      if(result) // success
      {
        if(stack.size() > 0) // there is a holder to bind to
        {
          if(cursor > previous) // token was consumed
          {
            stack.top().bind(node);
          }
          else
          {
            delete node;
          }
        }
      }
      else
      {
        delete node;
      }

      return result;
    }

Los métodos advance y commitState que hemos visto al estudiar las clases de los terminales están implementados de la siguiente manera (algunos detalles han sido omitidos).

    void advance(const Terminal *p)
    {
      production(p, cursor, tokens[cursor], stack.size());
      if(cursor < tokens.size() - 1) cursor++;
      
      if(cursor > maxcursor)
      {
        maxcursor = cursor;
        errors.clear();
      }
    }

    void commitState(const Terminal *p)
    {
      int x = states.pop();
      for(int i = x; i < cursor; i++)
      {
        production(p, i, tokens[i], stack.size());
      }
    }

Advance mueve el cursor a lo largo de la lista de los tokens. Si la posición se ha hecho más que la máxima, se puede eliminar los errores acumulados, porque se registran con cada verificación sin éxito.

El método production usa una interfaz de la llamada de retorno con el fin de avisar al usuario del analizador sobre el «producto», vamos a aplicarlo en adelante en las pruebas.

    void production(const Terminal *p, const int index, const Token *t, const int level)
    {
      if(callback) callback.produce(&this, p, index, t, source, level);
    }

La interfaz se define como:

interface Callback
{
  void produce(const Parser *parser, const Terminal *, const int index, const Token *, const Source *context, const int level);
  void backtrack(const int index);
};

El objeto que implementa esta interfaz en el lado del cliente puede conectarse al analizador usando el método setCallback, entonces, él va a invocarse en cada «producto». Como alternativa, se puede conectar este objeto de manera individual a cualquier terminal gracias a la sobrecarga del operador [Callback *]. Eso es útil para la depuración con el fin de colocar los puntos de la interrupción en determinados puntos de la gramática.

Usaremos el analizador sintáctico en la práctica.

Práctica, parte 1: calculadora

Ya tenemos la gramática de la calculadora. Vamos a crear un script de depuración para ella. Luego, vamos a completarlo para las pruebas con la gramática MQL.

#property script_show_inputs

enum TEST_GRAMMAR {Expression, MQL};

input TEST_GRAMMAR TestMode = Expression;;
input string SourceFile = "Sources/calc.txt";;
input string IncludesFolder = "Sources/Include/";;
input bool LoadIncludes = false;
input bool PrintCST = false;

#include <mql5/scanner.mqh>
#include <mql5/prsr.mqh>

void OnStart()
{
  Preprocessor loader(SourceFile, IncludesFolder, LoadIncludes);
  if(!loader.run())
  {
    Print("Loader failed");
    return;
  }

  Scanner scanner(loader.text());
  List<Token *> *tokens = scanner.scanTokens();
  
  if(!scanner.isSuccess())
  {
    Print("Tokenizer failed");
    delete tokens;
    return;
  }

  Parser parser(tokens, loader.text(), PrintCST);

  if(TestMode == Expression)
  {
    testExpressionGrammar(&parser);
  }
  else
  {
    //...
  }
  
  delete tokens;
}

void testExpressionGrammar(Parser *p)
{
  _FORWARD(expression);
  _DECLARE(value) = T(CONST_INTEGER) | T(LEFT_PAREN) + R(expression) + T(RIGHT_PAREN);
  _DECLARE(operation) = (T(PLUS) | T(STAR) | T(MINUS) | T(SLASH)) + R(expression);
  expression = R(value) + R(operation)*0;

  while(p.match(&expression) && !p.isAtEnd())
  {
    Print("", "Unexpected end");
    break;
  }

  if(p.isAtEnd())
  {
    Print("Success");
  }
  else
  {
    p.printState();
  }

  if(PrintCST)
  {
    Print("Concrete Syntax Tree:");
    TreePrinter printer(p);
    printer.printTree();
  }

  Comment("");
}

La esencia del script: leer el archivo transferido en el preprocesador, convertir en el flujo de los tokens usando el escáner y verificar con el analizador para la gramática especificada. La verificación se realiza llamando al método match, en el cual se pasa la regla raíz de la gramática, expression.

Como una opción (PrintCST), podemos mostrar un árbol sintáctico de la expresión procesada en el log usando la clase auxiliar TreePrinter.

¡Atención! Para los programas reales, el árbol será muy grande. Esta opción se recomienda sólo en la depuración de los pequeños fragmentos de la gramática, o si la gramática no es grande, como en el caso, de nuestra calculadora.

Al ejecutar un script de prueba para el archivo con la expresión "(10+1)*2", obtenemos el siguiente árbol (no olvidemos seleccionar TestMode igual a Expression y PrintCST - true):

Concrete Syntax Tree:
|  |  |Terminal LEFT_PAREN @ (
|  |   |  | |Terminal CONST_INTEGER @ 10
|  |   |  |NonTerminal value
|  |   |  |  |Terminal PLUS @ +
|  |   |  |  |  | |Terminal CONST_INTEGER @ 1
|  |   |  |  |  |NonTerminal value
|  |   |  |  |NonTerminal expression
|  |   |  |NonTerminal operation
|  |   |NonTerminal expression
|  |  |Terminal RIGHT_PAREN @ )
|  |NonTerminal value
|  |  |Terminal STAR @ *
|  |  |  | |Terminal CONST_INTEGER @ 2
|  |  |  |NonTerminal value
|  |  |NonTerminal expression
|  |NonTerminal operation
|NonTerminal expression

Las líneas verticales significan los niveles del procesamiento de los no terminales descritos explícitamente en la gramática (nombrados). Los espacios corresponden a los niveles donde «se abrían» los no terminales creados implícitamente de las clases HiddenXYZ, es que, los nodos no se muestran en el log por defecto, pero en la clase TreePrinter hay una opción para activarlos.

Nótese que la opción PrintCST trabaja a base de de una estructura especial con meta datos (árbol de objetos TreeNode). Nuestro analizador es capaz, opcionalmente, de mostrarla después del análisis en respuesta a la llamada al método getCST. Recordamos que la activación del modo del montaje del árbol se define por el tercer parámetro del constructor del analizador.

Se puede experimentar con otras expresiones, inclusive incorrectas, para cerciorarse de que hay tratamiento de errores. Por ejemplo, si se estropea una expresión, haciéndola "10+", recibimos el mensaje:

Failed
First 2 tokens read out of 3
Source: EOF (file:Sources/Include/Layouts/calc.txt; line:1; offset:4) ``
Expected:
CONST_INTEGER in expression;operation;expression;value;
LEFT_PAREN in expression;operation;expression;value;

Pues bien, todas las clases funcionan. Podemos proceder a la parte práctica principal, el análisis sintáctico de MQL.


Práctica, parte 2: gramática MQL

Desde el punto de vista técnico, tenemos todo listo para escribir la gramática de MQL. No obstante, ella es mucho más complicada que una pequeña calculadora. Crearla desde cero sería una tarea insuperable. Para resolver el problema, usaremos el hecho de que MQL es semejante a C++.

Se puede encontrar muchas descripciones hechas para C++ en acceso público. Uno de ellos se adjunta al artículo como archivo cppgrmr.htm. También es problemático traspasarlo en nuestra gramática. En primer lugar, muchas construcciones no se soportan en MQL. En segundo lugar, en la notación a menudo podemos ver la recursión izquierda, debido a la cual tenemos que alterar las reglas. Y finalmente, en tercer lugar, es deseable limitar el tamaño de la gramática, porque afecta de forma negativa la velocidad del procesamiento: tiene sentido dejar algunas «características» raramente usadas para una adición opcional por los que lo necesitan.

La secuencia en la que se mencionan las alternativas OR es importante, ya que la primera opción accionada procesa interrumpe las siguientes comprobaciones. Si, en ciertas condiciones, las variantes pueden coincidir parcialmente debido a la omisión de los elementos opcionales, hay que recomponerlos, o bien especificar, primero, las construcciones más largas y más específicas, y luego, las construcciones más cortas y más generales.

Vamos a demostrar cómo algunas notaciones de un archivo htm son transformadas en la gramática de nuestras reglas y terminales.

En la gramática C++:

assignment-expression:
  conditional-expression 
  unary-expression assignment-operator assignment-expression

assignment-operator: one of
  = *= /= %= += –= >= <= &= ^= |=

В MQL грамматике:

_FORWARD(assignment_expression);
_FORWARD(unary_expression);

...

assignment_expression =
    R(unary_expression) + R(assignment_operator) + R(assignment_expression)
  | R(conditional_expression);

_DECLARE(assignment_operator) =
    T(EQUAL) | T(STAR_EQUAL) | T(SLASH_EQUAL) | T(DIV_EQUAL)
  | T(PLUS_EQUAL) | T(MINUS_EQUAL) | T(GREATER_EQUAL) | T(LESS_EQUAL)
  | T(BIT_AND_EQUAL) | T(BIT_XOR_EQUAL) | T(BIT_OR_EQUAL);

En la gramática C++:

unary-expression:
  postfix-expression 
  ++ unary-expression 
  –– unary-expression 
  unary-operator cast-expression 
  sizeof unary-expression 
  sizeof ( type-name ) 
  allocation-expression 
  deallocation-expression

En la gramática MQL:

unary_expression =
    R(postfix_expression)
  | T(INC) + R(unary_expression) | T(DEC) + R(unary_expression)
  | R(unary_operator) + R(cast_expression)
  | T(SIZEOF) + T(LEFT_PAREN) + R(type) + T(RIGHT_PAREN)
  | R(allocation_expression) | R(deallocation_expression);

En la gramática C++:

statement:
  labeled-statement 
  expression-statement 
  compound-statement 
  selection-statement 
  iteration-statement 
  jump-statement 
  declaration-statement
  asm-statement
  try-except-statement
  try-finally-statement

En la gramática MQL:

statement =
    R(expression_statement) | R(codeblock) | R(selection_statement)
  | R(labeled_statement) | R(iteration_statement) | R(jump_statement);

La regla para declaration_statement también está presente en la gramática MQL, pero está transferida. Muchas reglas han sido escritas en forma simplificada, en comparación con C++. En principio, esta gramática es un organismo vivo, o como dicen los ingleses, «a work in progress». Es muy probable que haya errores al interpretar las construcciones específicas en los códigos fuente.

El punto de entrada para la gramática MQL es la regla program compuesta de 1 o más elementos:

  _DECLARE(element) =
     R(class_decl)
   | R(declaration_statement) | R(function) | R(sharp) | R(macro);

  _DECLARE(program) = R(element)*1;

En nuestro script de prueba, la gramática MQL presentada está descrita en la función testMQLgrammar:

void testMQLgrammar(Parser *p)
{
  // all grammar rules go first
  // ...
  _DECLARE(program) = R(element)*1;

En ella se ejecuta el analizador (por analogía con la calculadora):

  while(p.match(&program) && !p.isAtEnd())
  ...

Si ocurre un error, es necesario localizar el elemento problemático usando los logs, y depurar la regla determinada de la gramática en un fragmento de entrada separado del texto (se recomienda un fragmento con 5-6 tokens como mucho). En otras palabras, es necesario llamar al método match del analizador para una determinada regla, y enviar a la entrada un archivo con una construcción separada. Para mostrar los rastreos del analizador en el log, hay que quitar la marca del comentario para la directiva en el script:

//#define PRINTX Print

¡Atención! El volumen de la información de salida es muy grande.

Antes de a depuración, se recomienda distribuir diferentes elementos de la regla por cadenas diferentes, porque eso marcará las copias anónimas de los objetos con números exclusivos de las cadenas de origen.
Por lo demás, el analizador sintáctico no se creaba para comprobar la correspondencia del texto al sintaxis de MQL, sino para extraer los datos sintácticos. Intentaremos hacerlo.

Práctica, parte 3: lista de los métodos de las clases y jerarquía de las clases

Como la primera tarea aplicada a base del análisis sintáctico MQL, compilamos una lista de todos los métodos de las clases. Para eso, determinaremos la clase que realiza la interfaz Callback, y vamos a registrar los «productos» que nos interesan.

En principio, sería más lógico realizar el análisis sintáctico a base de un árbol sintáctico. Pero eso significa la sobrecarga de la memoria para el almacenamiento del árbol y un algoritmo separado para recorrer este árbol. No obstante, el recorrido en la misma orden se hace prácticamente por el mismo analizador durante el análisis del texto (es que, precisamente en esta secuencia, el árbol se construye si tenemos activado el modo correspondiente). Por eso, es más fácil realizar el análisis en tiempo real.

En la gramática MQL, hay siguiente regla:

  _DECLARE(method) = R(template_decl)*0 + R(method_specifiers)*0 + R(type) + R(name_with_arg_list) + R(modifiers)*0;

Se compone de muchos otros no terminales los cuales, a su vez, se revelan a través de otros no terminales, por tanto, el árbol sintáctico del método es muy extenso. En el manipulador del producto, vamos a captar de todos los fragmentos pertenecientes al no terminal "method" y colocarlos en una cadena común. En el momento cuando el próximo producto sea para otro no terminal, entonces, la descripción del método ha finalizado, y se puede mostrar el resultado en el log.

class MyCallback: public Callback
{
    virtual void produce(const Parser *parser, const Terminal *p, const int index, const Token *t, const Source *context, const int level) override
    {
      static string method = "";
      
      // collect all tokens from `method` nonterminal
      if(p.getName() == "method")
      {
        method += t.content(context) + " ";
      }
      // as soon as other [non]terminal is detected and string is filled, signature is ready
      else if(method != "")
      {
        Print(method);
        method = "";
      }
    }

Para conectar su propio manipulador al analizador, completamos el script de prueba de la siguiente manera (в OnStart):

  MyCallback myc;
  Parser parser(tokens, loader.text(), PrintCST);
  parser.setCallback(&myc);

Además de la lista de los métodos, recopilamos la información sobre la declaración de las clases. Será necesaria, en particular, para definir el contexto en el que los métodos están definidos, pero también podemos construir la jerarquía de la herencia.

Para almacenar la meta información sobre una clase aleatoria, preparamos la clase Class ;-).

  class Class
  {
    private:
      BaseArray<Class *> subclasses;
      Class *superclass;
      string name;

    public:
      Class(const string n): name(n), superclass(NULL)
      {
      }
      
      ~Class()
      {
        subclasses.clear();
      }
      
      void addSubclass(Class *derived)
      {
        derived.superclass = &this;
        subclasses.add(derived);
      }
      
      bool hasParent() const
      {
        return superclass != NULL;
      }
      
      Class *operator[](int i) const
      {
        return subclasses[i];
      }
      
      int size() const
      {
        return subclasses.size();
      }
      ...
   };

Contiene el array de los herederos subclasses y la referencia al padre superclass. El método addSubclass se encarga del llenado de estos campos interrelacionados. Las instancias de los objetos Class se colocan en el mapa con una clave string en forma del nombre de la clase:

  Map<string,Class *> map;

El mapa se encuentra en el objeto MyCallback.

Ahora, podemos completar el método produce desde la interfaz Callback. Para recopilar los tokens relacionados a la declaración de la clase, tenemos que interceptar más reglas, porque necesitamos la declaración entera con propiedades específicas seleccionadas: nombre de nueva clase, tipos de su plantilla (si hay), nombre de la clase base y tipos de su plantilla (si hay).

Añadimos las variables correspondientes para recopilar los datos (atención, las clases en MQL pueden ser anidadas, aunque eso ocurre raras veces, pero aquí no consideramos este asunto para simplificar. Al mismo tiempo, nuestra gramática MQL no se soporta).

      static string templName = "";
      static string templBaseName = "";
      static string className = "";
      static string baseName = "";

Los identificadores en el contexto del no terminal "template_decl" son tipos de plantilla:

      if(p.getName() == "template_decl" && t.getType() == IDENTIFIER)
      {
        if(templName != "") templName += ",";
        templName += t.content(context);
      }

En los códigos fuente adjuntos, se puede estudiar las reglas de la gramática correspondiente para "template_decl" y los objetos usados más abajo.

El identificador en el contexto del no terminal "class_name" es el nombre de la clase, y si templName no es una cadena vacía en este momento, entonces, se trata de los tipos de plantilla que tienen que ser añadidos a la descripción:

      if(p.getName() == "class_name" && t.getType() == IDENTIFIER)
      {
        className = t.content(context);
        if(templName != "")
        {
          className += "<" + templName + ">";
          templName = "";
        }
      }

El primer identificador en el contexto "derived_clause" (si existe) es el nombre de la clase base.

      if(p.getName() == "derived_clause" && t.getType() == IDENTIFIER)
      {
        if(baseName == "") baseName = t.content(context);
        else
        {
          if(templBaseName != "") templBaseName += ",";
          templBaseName += t.content(context);
        }
      }

Todos los siguientes identificadores son los tipos de plantilla de la clase base.

Cuando la declaración de la clase está finalizada, se acciona la regla gramatical "class_decl". En este momento, todos los datos ya están recopilados y se puede colocarlos en el mapa de las clases.

      if(p.getName() == "class_decl") // finalization
      {
        if(className != "")
        {
          if(map[className] == NULL)
          {
            map.put(className, new Class(className));
          }
          else
          {
            // Class already defined, maybe forward declaration
          }
        }
      
        if(baseName != "")
        {
          if(templBaseName != "")
          {
            baseName += "<" + templBaseName + ">";
          }
          Class *base = map[baseName];
          if(base == NULL)
          {
            // Unknown class, maybe not included, but strange
            base = new Class(baseName);
            map.put(baseName, base);
          }
          
          if(map[className] == NULL)
          {
            Print("Error: base name `", baseName, "` resolved before declaration of the class: ", className);
          }
          else
          {
            base.addSubclass(map[className]);
          }
          
          baseName = "";
        }
        className = "";
        templName = "";
        templBaseName = "";
      }

Al final, limpiamos todas las líneas y esperamos la aparición de las siguientes declaraciones.

Después del análisis sintáctico bien concluido del texto de programa, nos queda mostrar la jerarquía de las clases de cualquier manera conveniente. En el script de prueba, la clase MyCallback proporciona la función print para mostrar en el log. Ella, a su vez, utiliza el método print en los objetos de la clase Class. Dejamos estos algoritmos auxiliares para estudiarlos por cuenta propia, y además de eso, como un pequeño ejercicio de programación para los que desean medir sus habilidades (estas competiciones surgen a menudo de forma espontánea en el foro de mql5.com). La implementación existente es puramente utilitaria, y no pretende ser ideal, ella simplemente proporciona un objetivo: la visualización de la jerarquía arbórea de objetos tipo Class en el log de forma más obvia. Pero seguramente se puede hacerlo de forma más eficaz.

Vamos a comprobar el funcionamiento del script de prueba para el análisis de algunos proyectos MQL. Aquí, a continuación, definimos el parámetro TestMode = MQL.

Por ejemplo, para el EA estándar "MACD Sample.mq5", al definir SourceFile = "Sources/Experts/Examples/MACD/MACD Sample.mq5", así como, LoadIncludes = true, es decir, con todas las dependencias, obtenemos el siguiente resultado (la lista de los métodos es bastante reducida):

Processing Sources/Experts/Examples/MACD/MACD Sample.mq5
Scanning...
#include <Trade\Trade.mqh>
Including Sources/Include/Trade\Trade.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "StdLibErr.mqh"
Including Sources/Include/StdLibErr.mqh
#include "OrderInfo.mqh"
Including Sources/Include/Trade/OrderInfo.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "SymbolInfo.mqh"
Including Sources/Include/Trade/SymbolInfo.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "PositionInfo.mqh"
Including Sources/Include/Trade/PositionInfo.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "SymbolInfo.mqh"
Including Sources/Include/Trade/SymbolInfo.mqh
#include <Trade\PositionInfo.mqh>
Including Sources/Include/Trade\PositionInfo.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "SymbolInfo.mqh"
Including Sources/Include/Trade/SymbolInfo.mqh
Files processed: 8
Source length: 175860
File map:
Sources/Experts/Examples/MACD/MACD Sample.mq5 0
Sources/Include/Trade\Trade.mqh 900
Sources/Include/Object.mqh 1277
Sources/Include/StdLibErr.mqh 1657
Sources/Include/Object.mqh 2330
Sources/Include/Trade\Trade.mqh 3953
Sources/Include/Trade/OrderInfo.mqh 4004
Sources/Include/Trade/SymbolInfo.mqh 4407
Sources/Include/Trade/OrderInfo.mqh 38837
Sources/Include/Trade\Trade.mqh 59925
Sources/Include/Trade/PositionInfo.mqh 59985
Sources/Include/Trade\Trade.mqh 75648
Sources/Experts/Examples/MACD/MACD Sample.mq5 143025
Sources/Include/Trade\PositionInfo.mqh 143091
Sources/Experts/Examples/MACD/MACD Sample.mq5 158754
Lines: 4170
Tokens: 18005
Defining grammar...
Parsing...
CObject :: CObject * Prev ( void ) const 
CObject :: void Prev ( CObject * node ) 
CObject :: CObject * Next ( void ) const 
CObject :: void Next ( CObject * node ) 
CObject :: virtual bool Save ( const int file_handle ) 
CObject :: virtual bool Load ( const int file_handle ) 
CObject :: virtual int Type ( void ) const 
CObject :: virtual int Compare ( const CObject * node , const int mode = 0 ) const 
CSymbolInfo :: string Name ( void ) const 
CSymbolInfo :: bool Name ( const string name ) 
CSymbolInfo :: bool Refresh ( void ) 
CSymbolInfo :: bool RefreshRates ( void ) 

...

CSampleExpert :: bool Init ( void ) 
CSampleExpert :: void Deinit ( void ) 
CSampleExpert :: bool Processing ( void ) 
CSampleExpert :: bool InitCheckParameters ( const int digits_adjust ) 
CSampleExpert :: bool InitIndicators ( void ) 
CSampleExpert :: bool LongClosed ( void ) 
CSampleExpert :: bool ShortClosed ( void ) 
CSampleExpert :: bool LongModified ( void ) 
CSampleExpert :: bool ShortModified ( void ) 
CSampleExpert :: bool LongOpened ( void ) 
CSampleExpert :: bool ShortOpened ( void ) 
Success
Class hierarchy:

CObject
  ^
  +--CSymbolInfo
  +--COrderInfo
  +--CPositionInfo
  +--CTrade
  +--CPositionInfo

CSampleExpert

Ahora, vamos a probar con un proyecto de terceros. He usado el EA SlidingPuzzle2 de este artículo. Lo tengo ubicado en la ruta SourceFile = "Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5". Además, al incluir todos los archivos de cabecera (LoadIncludes = true), obtenemos el resultado (reducido):

Processing Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5
Scanning...
#include "SlidingPuzzle2.mqh"
Including Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh
#include <Layouts\GridTk.mqh>
Including Sources/Include/Layouts\GridTk.mqh
#include "Grid.mqh"
Including Sources/Include/Layouts/Grid.mqh
#include "Box.mqh"
Including Sources/Include/Layouts/Box.mqh
#include <Controls\WndClient.mqh>
Including Sources/Include/Controls\WndClient.mqh
#include "WndContainer.mqh"
Including Sources/Include/Controls/WndContainer.mqh
#include "Wnd.mqh"
Including Sources/Include/Controls/Wnd.mqh
#include "Rect.mqh"
Including Sources/Include/Controls/Rect.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "StdLibErr.mqh"
Including Sources/Include/StdLibErr.mqh
#include "Scrolls.mqh"
Including Sources/Include/Controls/Scrolls.mqh
#include "WndContainer.mqh"
Including Sources/Include/Controls/WndContainer.mqh
#include "Panel.mqh"
Including Sources/Include/Controls/Panel.mqh
#include "WndObj.mqh"
Including Sources/Include/Controls/WndObj.mqh
#include "Wnd.mqh"
Including Sources/Include/Controls/Wnd.mqh
#include <Controls\Edit.mqh>
Including Sources/Include/Controls\Edit.mqh
#include "WndObj.mqh"
Including Sources/Include/Controls/WndObj.mqh
#include <ChartObjects\ChartObjectsTxtControls.mqh>
Including Sources/Include/ChartObjects\ChartObjectsTxtControls.mqh
#include "ChartObject.mqh"
Including Sources/Include/ChartObjects/ChartObject.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
Files processed: 17
Source length: 243134
File map:
Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5 0
Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh 493
Sources/Include/Layouts\GridTk.mqh 957
Sources/Include/Layouts/Grid.mqh 1430
Sources/Include/Layouts/Box.mqh 1900
Sources/Include/Controls\WndClient.mqh 2377
Sources/Include/Controls/WndContainer.mqh 2760
Sources/Include/Controls/Wnd.mqh 3134
Sources/Include/Controls/Rect.mqh 3509
Sources/Include/Controls/Wnd.mqh 14312
Sources/Include/Object.mqh 14357
Sources/Include/StdLibErr.mqh 14737
Sources/Include/Object.mqh 15410
Sources/Include/Controls/Wnd.mqh 17033
Sources/Include/Controls/WndContainer.mqh 46214
Sources/Include/Controls\WndClient.mqh 61689
Sources/Include/Controls/Scrolls.mqh 61733
Sources/Include/Controls/Panel.mqh 62137
Sources/Include/Controls/WndObj.mqh 62514
Sources/Include/Controls/Panel.mqh 72881
Sources/Include/Controls/Scrolls.mqh 78251
Sources/Include/Controls\WndClient.mqh 103907
Sources/Include/Layouts/Box.mqh 115349
Sources/Include/Layouts/Grid.mqh 126741
Sources/Include/Layouts\GridTk.mqh 131057
Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh 136066
Sources/Include/Controls\Edit.mqh 136126
Sources/Include/ChartObjects\ChartObjectsTxtControls.mqh 136555
Sources/Include/ChartObjects/ChartObject.mqh 137079
Sources/Include/ChartObjects\ChartObjectsTxtControls.mqh 177423
Sources/Include/Controls\Edit.mqh 213551
Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh 221772
Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5 241539
Lines: 6102
Tokens: 27248
Defining grammar...
Parsing...
CRect :: CPoint LeftTop ( void ) const 
CRect :: void LeftTop ( const int x , const int y ) 
CRect :: void LeftTop ( const CPoint & point ) 

...

CSlidingPuzzleDialog :: virtual bool Create ( const long chart , const string name , const int subwin , const int x1 , const int y1 , const int x2 , const int y2 ) 
CSlidingPuzzleDialog :: virtual bool OnEvent ( const int id , const long & lparam , const double & dparam , const string & sparam ) 
CSlidingPuzzleDialog :: void Difficulty ( int d ) 
CSlidingPuzzleDialog :: virtual bool CreateMain ( const long chart , const string name , const int subwin ) 
CSlidingPuzzleDialog :: virtual bool CreateButton ( const int button_id , const long chart , const string name , const int subwin ) 
CSlidingPuzzleDialog :: virtual bool CreateButtonNew ( const long chart , const string name , const int subwin ) 
CSlidingPuzzleDialog :: virtual bool CreateLabel ( const long chart , const string name , const int subwin ) 
CSlidingPuzzleDialog :: virtual bool IsMovable ( CButton * button ) 
CSlidingPuzzleDialog :: virtual bool HasNorth ( CButton * button , int id , bool shuffle = false ) 
CSlidingPuzzleDialog :: virtual bool HasSouth ( CButton * button , int id , bool shuffle = false ) 
CSlidingPuzzleDialog :: virtual bool HasEast ( CButton * button , int id , bool shuffle = false ) 
CSlidingPuzzleDialog :: virtual bool HasWest ( CButton * button , int id , bool shuffle = false ) 
CSlidingPuzzleDialog :: virtual void Swap ( CButton * source ) 
Success
Class hierarchy:

CPoint

CSize

CRect

CObject
  ^
  +--CWnd
  |    ^
  |    +--CDragWnd
  |    +--CWndContainer
  |    |    ^
  |    |    +--CScroll
  |    |    |    ^
  |    |    |    +--CScrollV
  |    |    |    +--CScrollH
  |    |    +--CWndClient
  |    |         ^
  |    |         +--CBox
  |    |              ^
  |    |              +--CGrid
  |    |                   ^
  |    |                   +--CGridTk
  |    +--CWndObj
  |         ^
  |         +--CPanel
  |         +--CEdit
  +--CGridConstraints
  +--CChartObject
       ^
       +--CChartObjectText
            ^
            +--CChartObjectLabel
                 ^
                 +--CChartObjectEdit
                 |    ^
                 |    +--CChartObjectButton
                 +--CChartObjectRectLabel

CAppDialog
  ^
  +--CSlidingPuzzleDialog

Aquí, la jerarquía de las clases es más interesante.

A pesar de que yo testeé el analizador sintáctico en diferentes proyectos, es absolutamente seguro que va a «tropezarse» con algunos programas. Uno de los problemas no resueltos está relacionado con el procesamiento de las macros. Como ya ha sido dicho antes, un análisis correcto supone la interpretación dinámica y la apertura de las macros con la substitución de los resultados en el código fuente antes de comenzar el análisis sintáctico.

En la gramática MQL actual, ha sido hecho un intento de designar la llamada a las macros como una llamada a las funciones menos estricta, pero eso no siempre funciona.

Por ejemplo, en la biblioteca TypeToBytes, los parámetros de las macros se usan para generar los meta tipos. Vea uno de los casos:

#define _C(A, B) CASTING<A>::Casting(B)

Luego, este macro se usa de la siguiente manera:

Res = _C(STRUCT_TYPE<T1>, Tmp);

Si intentamos iniciar el analizador en este código, él no podrá «digerir» STRUCT_TYPE<T1>, porque, en realidad, este parámetro representa un tipo de plantilla, y el analizador sobreentiende un valor, o una expresión, en el sentido más amplio (y, en particular, interpreta los caracteres '<', '>' como las operaciones de comparación). Ahora, el problema semejante va a crearse por las llamadas de las macros, después de las cuales no hay punto con coma Pero se puede evitar este problema insertando ';' en el código fuente procesado.

Los interesados pueden realizar el experimento número 3 (los dos primeros han sido mencionados al principio del artículo), que consiste en la búsqueda de una solución alternativa para modificar la gramática actual con reglas de las macros que permitan analizar estos casos complicados con éxito.

Conclusión

Hemos considerado la manera más simple y eficiente del análisis sintáctico de los datos, incluyendo el análisis de los códigos fuente en MQL. Para eso, ha sido presentada la gramática del lenguaje MQL y la implementación de las herramientas estándar: escáner y analizador sintáctico. Las estructuras de los códigos fuente obtenidas gracias a ellas permiten calcular su estadística, determinar los indicadores de la calidad, construir las dependencias, cambiar automáticamente el formateo.

Además de eso, la implementación presentada requiere una serie de mejor para conseguir la compatibilidad a 100% con los proyectos complejos MQL, en particular, en términos del soporte de la expansión de las macros.

Con una preparación más profunda, guardando la información sobre las entidades encontradas en la tabla de los nombres, este enfoque también permitirá generar el código, y monitorear los errores típicos y ejecutar otras tareas más complejas.