DEV Community

Cover image for Reinventando a Roda: Criando um compilador em csharp - Parte 3
Angelo Belchior
Angelo Belchior

Posted on

Reinventando a Roda: Criando um compilador em csharp - Parte 3

Para quem estava duvidando, chegamos ao terceiro post! E nele vamos adicionar o suporte a declaração de variáveis.

Para atender essa nova feature foram necessárias algumas alterações e, com isso, precisei dar uma reorganizada no código.

Mas não se preocupe, nós vamos passar por cada arquivo, sempre detalhadamente, cobrindo todos os pontos importantes.

O mais interessante é que, de agora em diante, vamos começar a ter um esboço de como ficará a nossa linguagem de programação. Aliás, existem Programming language designers — uma pessoa que pensa em como deveria ser uma linguagem de programação no que tange à sua escrita. O glorioso Anders Hejlsberg pai do csharp e de outras linguagens, é um bom exemplo disso. Outra figura notória é o grande Eric Lippert cujo blog é simplesmente sensacional. Mais do que recomendo.

Pois bem! Seguindo nessa linha, minha ideia é criar algo simples, com poucos tokens. Por exemplo, para declarar uma variável na nossa linguagem de programação, vai ser bem simples:

int idade
string nome = "Angelo"
bool sucesso = true double altura = 1.4
int valor = idade * pow(altura)
Enter fullscreen mode Exit fullscreen mode

Sem ;, sem se preocupar em quebrar linha, tabs ou espaços.
Podendo ou não informar um valor logo na declaração. Coisa linda…

Mas a ideia nessa série de posts é sempre fazermos o que for mais simples possível e incrementando aos poucos.

Sendo assim, para essa primeira versão teremos os tipos int, double, string e bool.

O tipo bool vai ser o menos explorado hoje, já que ele vai ser muito importante quando adicionarmos suporte a operadores lógicos como ==, !=, >, >=, <, <=, && e ||. Essas implementações estarão no próximo post.

Além disso, nessa parte 3 vamos incluir alguns outros métodos internos, como o print, por exemplo. Com isso, vamos poder brincar com string e concatenação.

Chega de papo, vamos ao que viemos.

Reorganização do código

Mantemos a mesma ideia, porém com uma estrutura mais organizada. Extraímos códigos de Runtime como a execução de métodos internos para um novo namespace chamado, vejam só… Runtime.

A parte de análise de código (CodeAnalysis) continua com a mesma estrutura, porém as classes Lexer e SyntaxParser foram refatoradas. Isso foi necessário já que para incluir mais tokens precisamos criar mais códigos de extração, além de mais validações estruturais das expressões. Porém, nada que não esteja nas ideias apresentadas na parte 1 e na parte 2.

Aliás, na parte 2, eu disse que “depois que entendemos e criamos o lexer e o parser, evoluí-los é algo bem mais tranquilo”. Não sei o que deu em mim para afirmar isso…

Outro ponto muito importante. Temos testes unitários. Ok, não temos uma cobertura gigantesca, mas vou evoluindo isso com o tempo. O fato é que, a cada nova feature, mais complexo fica o debugger e a análise. Testes são fundamentais, independente do cenário.

Sendo assim, temos a seguinte estrutura:

Pug.Compiler
├── CodeAnalysis/
│   ├── Lexer.cs
│   ├── SyntaxParser.cs
│   ├── Token.cs
│   └── TokenType.cs
├── Runtime/
│   ├── BuiltInFunctions.cs
│   ├── DataTypes.cs
│   └── Identifier.cs
└── Program.cs
Enter fullscreen mode Exit fullscreen mode

Bom, se você leu a parte 1 e a parte 2 sabe que o processo se inicia com a extração de tokens efetuada pelo Lexer.

Para suportar a declaração de variáveis, precisamos adicionar novos tokens:

Arquivo CodeAnalysis/TokenType.cs:

public enum TokenType
{
    EndOfFile,

    Number,
    Bool,
    String,

    Plus,
    Minus,
    Multiply,
    Divide,

    Assign,

    OpenParenthesis,
    CloseParenthesis,

    Function,
    Comma,

    Identifier,
    DataType
}
Enter fullscreen mode Exit fullscreen mode

Dessa vez temos Bool, String, Assign, Identifier e DataType.

Você pode estar se perguntando: Cadê os tokens int e double?

Aqui temos um ponto fundamental. int e double, assim como string e bool são tipos de dados e tipos de dados a gente vai mapear como DataType. Além disso, o nome da variável vai ser mapeado como Identifier (talvez num futuro eu mude para que os nomes das funções também sejam mapeados como Identifier).

Certo, mas por quais motivos temos os TokenType Number, String e Bool?
Simples: eles mapeiam constantes. Por exemplo.

  • 123 gera um token do tipo Number com valor 123.
  • "Olá Mundo" gera um token do tipo String com valor "Olá Mundo".
  • true gera um token do tipo Bool com valor true.
  • false gera um token do tipo Bool com valor false.

Com isso, ao executar a expressão: string nome = "Angelo" teremos a seguinte lista de tokens:

> string nome = "Angelo"
TokenType.DataType => string (Position: 0)
TokenType.Identifier => nome (Position: 7)
TokenType.Assign => = (Position: 12)
TokenType.String => Angelo (Position: 14)
TokenType.EndOfFile =>  (Position: 22)
Angelo
Enter fullscreen mode Exit fullscreen mode

Vamos analisar a classe Lexer

Arquivo CodeAnalysis/Lexer:

public class Lexer
{
    private readonly string _text;

    private int _currentPosition;
    private char _currentChar;

    public Lexer(string text)
    {
        _text = text;
        _currentPosition = 0;
        _currentChar = _text.Length > 0 ? _text[_currentPosition] : Token.END_OF_FILE;
    }

    public List<Token> ExtractTokens()
    {
        var tokens = new List<Token>();

        while (_currentChar != Token.END_OF_FILE)
        {
            if (char.IsWhiteSpace(_currentChar))
            {
                IgnoreWhitespace();
                continue;
            }

            if (char.IsLetter(_currentChar))
            {
                tokens.Add(ExtractKeyword());
                continue;
            }

            if (_currentChar == Token.QUOTE)
            {
                tokens.Add(ExtractString());
                continue;
            }

            if (_currentChar == Token.ASSIGN)
            {
                tokens.Add(Token.Assign(_currentPosition));
                Next();
                continue;
            }

            if (_currentChar == Token.MINUS && char.IsDigit(Peek()) || char.IsDigit(_currentChar))
            {
                tokens.Add(ExtractNumber());
                continue;
            }

            tokens.Add(ExtractSymbols());
        }

        tokens.Add(Token.EndOfFile(_currentPosition));
        return tokens;
    }

    private Token ExtractKeyword()
    {
        var position = _currentPosition;
        var identifier = ExtractIdentifier();

        if (Identifier.ContainsDataType(identifier.Value))
            return Token.DataType(position, identifier.Value);

        if (BuiltInFunctions.Contains(identifier.Value))
            return Token.Function(position, identifier.Value);

        if (identifier.Value is Token.TRUE or Token.FALSE)
            return Token.Bool(position, identifier.Value);

        return Token.Identifier(position, identifier.Value);
    }

    private Token ExtractString()
    {
        var position = _currentPosition;

        Next();
        var stringValue = new StringBuilder();

        while (_currentChar != Token.QUOTE && _currentChar != Token.END_OF_FILE)
        {
            stringValue.Append(_currentChar);
            Next();
        }

        if (_currentChar != Token.QUOTE)
            throw new Exception("String not closed");

        Next();
        return Token.String(position, stringValue.ToString());
    }

    private Token ExtractSymbols()
    {
        var position = _currentPosition;
        var token = _currentChar switch
        {
            Token.PLUS => Token.Plus(position),
            Token.MINUS => Token.Minus(position),
            Token.MULTIPLY => Token.Multiply(position),
            Token.DIVIDER => Token.Divide(_currentPosition),
            Token.OPEN_PARENTHESIS => Token.OpenParenthesis(position),
            Token.CLOSE_PARENTHESIS => Token.CloseParenthesis(position),
            Token.COMMA => Token.Comma(position),
            _ => throw new Exception($"Unexpected character {_currentChar} at position {position}")
        };

        Next();
        return token;
    }

    private Token ExtractNumber()
    {
        var position = _currentPosition;
        var number = new StringBuilder();
        var hasDot = false;

        if (_currentChar == Token.MINUS)
        {
            number.Append(Token.MINUS);
            Next();
        }

        while (char.IsDigit(_currentChar) || _currentChar == Token.DOT)
        {
            if (_currentChar == Token.DOT)
            {
                if (hasDot)
                    throw new Exception("Invalid number format: multiple dots");

                hasDot = true;
            }

            number.Append(_currentChar);
            Next();
        }

        if (hasDot && !double.TryParse(number.ToString(), out _))
            throw new Exception($"Invalid number format: {number}");

        return Token.Number(position, number.ToString());
    }

    private Token ExtractIdentifier()
    {
        var position = _currentPosition;
        var result = new StringBuilder();

        while (char.IsLetter(_currentChar))
        {
            result.Append(_currentChar);
            Next();
        }

        return Token.Identifier(position, result.ToString());
    }

    private char Peek()
    {
        var position = _currentPosition + 1;
        return position < _text.Length ? _text[position] : Token.END_OF_FILE;
    }

    private void Next()
    {
        _currentPosition++;
        _currentChar = _currentPosition < _text.Length ? _text[_currentPosition] : Token.END_OF_FILE;
    }

    private void IgnoreWhitespace()
    {
        while (char.IsWhiteSpace(_currentChar))
            Next();
    }
}
Enter fullscreen mode Exit fullscreen mode

Ela cresceu bastante desde a parte 2, mas, sinceramente, nenhuma mudança brusca.

O que temos de novidade é que cada token agora tem seu próprio método de extração. Isso facilita muito a leitura e a manutenção do código. Antes, o método ExtractTokens continha muitas linhas e a tendência era crescer cada vez mais. Hoje, ele trata somente de identificar qual é o token em questão e mandar para o método que faz sua extração.

Vamos dissecar alguns desses extratores.

ExtractIdentifier: Ele extrai palavras sem espaços. Basicamente, vamos extrair nomes de variáveis (e talvez futuramente nomes de métodos). Aqui não tem segredo.

ExtractSymbols: Extrai os tokens de +, -, ( e etc. Removi do método ExtractTokens e centralizei aqui.

ExtractString: Extrai o conteúdo que estiver entre ". Simples assim.

ExtractKeyword: Esse extrator identifica tokens mapeados como true, false, int, double, string. Basicamente, todas as palavras suportadas pela linguagem de programação.

Nesse momento, você pode achar que os métodos ExtractIdentifier e ExtractKeyword fazem o mesmo, mas perceba que o Identifier não valida se aquela palavra existe no conjunto de palavras permitidas pela linguagem de programação. Essa é a principal diferença. Inclusive, eu tinha feito tudo no método ExtractIdentifier mas quando dei por conta ele parecia uma curva de rio.

Fazer essa separação vai facilitar quando incluirmos tokens como if e else por exemplo.


A classe Token não teve nenhuma mudança profunda. Somente a converti de record para class, já que dessa forma consigo criar um construtor privado e com parâmetros opcionais, e adicionei suporte a criação dos novos tokens.

Também criei algumas constantes. Somente isso…


Bem, nesse ponto já conseguimos identificar novos tokens e agora precisamos alterar nosso analisador sintático para ser possível efetuar o evaluate das expressões.

As alterações feitas na classe SyntaxParser seguem a mesma ideia da Parser: quebrar métodos por responsabilidade.

O fluxo de execução não mudou, somente está considerando novas expressões como a declaração e atribuição de valores a variáveis, concatenação e algumas operações entre strings além do suporte ao cálculo de expressões numéricas respeitando a prioridade. Isso está bem detalhado na parte 2.

Dentre as mudanças, destaco a criação do método Evaluate que nada mais é do que o antigo método Parser, porém dessa vez vamos retornar uma lista de Identifier.

Esse é um ponto interessante:

Como podemos ter várias expressões dentro do nosso código, cada expressão vai ter um retorno. Nesse momento centralizei no Identifier e acredito que vamos evoluir isso posteriormente. Antes, retornávamos double já que dávamos apenas suporte a operações matemáticas.

Nessa terceira parte, podemos ter operações entre tipos de dados, além de execuções de funções internas que não retornam valor, como, por exemplo, a print.

Quer um exemplo disso? Saca só:

int idade = 41
string nome = "Angelo"
print(nome + " tem " + idade + " anos de idade")
Enter fullscreen mode Exit fullscreen mode

Resultado do processamento:

> int idade = 41
string nome = "Angelo"
print(nome + " tem " + idade + " anos de idade")
Angelo tem 41 anos de idade
41
Angelo
{espaço em branco}
Enter fullscreen mode Exit fullscreen mode

Basicamente, temos a declaração de duas variáveis — idade e nome - e em seguida nós damos um print num texto.

A execução desse código-fonte resulta no texto "Angelo tem 41 anos de idade".
Em seguida, temos a impressão do evaluate de cada expressão:

  • 41: evaluate de int idade = 41
  • Angelo: evaluate de string nome = "Angelo"
  • {espaço em branco}: evaluate de print(nome + " tem " + idade + " anos de idade"). Como print retorna um identifier cujo valor é None (veremos mais pra frente), nenhum resultado dessa execução é impresso no console.

Cada expressão dessas é representada por um Identifier. E eu sei que você quer saber que diabos é isso... Sendo assim, antes da gente esmiuçar a classe SyntaxParser vamos olhar com calma algumas coisas do nosso novo namespace Runtime.

Arquivo Runtime/Identifier.cs

public class None
{
    public static readonly None Value = new();
}

public class Identifier(DataTypes dataType, object value)
{
    private const string IntType = "int";
    private const string DoubleType = "double";
    private const string BoolType = "bool";
    private const string StringType = "string";

    private static readonly Dictionary<string, Func<Identifier, Identifier>> TypeConverters = new()
    {
        [IntType] = value => new Identifier(DataTypes.Int, value.ToInt()),
        [DoubleType] = value => new Identifier(DataTypes.Double, value.ToDouble()),
        [BoolType] = value => new Identifier(DataTypes.Bool, value.ToBool()),
        [StringType] = value => new Identifier(DataTypes.String, value.ToString()),
    };

    public static readonly Identifier None = new(DataTypes.None, Pug.Compiler.Runtime.None.Value);

    public DataTypes DataType { get; } = dataType;

    public object Value
        => DataType switch
        {
            DataTypes.Int => ToInt(),
            DataTypes.Double => ToDouble().ToString(CultureInfo.InvariantCulture),
            DataTypes.Bool => ToBool().ToString().ToLower(),
            DataTypes.String => ToString(),
            DataTypes.None => Identifier.None,
            _ => throw new Exception($"Invalid data type: {DataType}")
        };

    public static Identifier Create<T>(DataTypes types, T value)
        => new(types, value is null ? None.Value : value);

    public static Identifier FromToken(Token token)
        => token.Type switch
        {
            TokenType.Number => new Identifier(DataTypes.Double,
                double.Parse(token.Value, CultureInfo.InvariantCulture)),
            TokenType.Bool => new Identifier(DataTypes.Bool, token.Value),
            TokenType.String => new Identifier(DataTypes.String, token.Value),
            _ => throw new Exception($"Invalid token type: {token.Type}")
        };

    public static bool ContainsDataType(string type)
        => TypeConverters.ContainsKey(type);

    public static Identifier Default(string typeName)
        => typeName switch
        {
            IntType => new Identifier(DataTypes.Int, 0),
            DoubleType => new Identifier(DataTypes.Double, 0),
            BoolType => new Identifier(DataTypes.Bool, false),
            StringType => new Identifier(DataTypes.String, string.Empty),
            _ => throw new Exception($"Invalid data type: {typeName}")
        };

    public Identifier Cast(string typeName)
        => DataType switch
        {
            DataTypes.Double when typeName != IntType && typeName != DoubleType
                => throw new Exception($"Invalid type {IntType} or {DoubleType}. Expected a {typeName}"),
            DataTypes.Int when typeName != IntType && typeName != DoubleType
                => throw new Exception($"Invalid type {IntType} or {DoubleType}. Expected a {typeName}"),
            DataTypes.String when typeName != StringType
                => throw new Exception($"Invalid type string. Expected a {typeName}"),
            DataTypes.Bool when typeName != BoolType
                => throw new Exception($"Invalid type bool. Expected a {typeName}"),
            _ => TypeConverters.TryGetValue(typeName, out var cast)
                ? cast(this)
                : throw new Exception($"Unknown type: {typeName}")
        };


    public double ToDouble()
    {
        var @string = ToString();
        var success = double.TryParse(@string, out var result);
        if (!success)
            throw new Exception($"Can't convert {value} to double");

        return result;
    }

    public int ToInt()
    {
        var @string = ToString();
        var success = int.TryParse(@string, out var result);
        if (!success)
            throw new Exception($"Can't convert {value} to int");

        return result;
    }

    public bool ToBool()
    {
        var @string = ToString();
        var success = bool.TryParse(@string, out var result);
        if (!success)
            throw new Exception($"Can't convert {value} to bool");

        return result;
    }

    public override string ToString()
        => value.ToString() ?? string.Empty;
}
Enter fullscreen mode Exit fullscreen mode

Sim, eu sou desses que coloca dois ou mais tipos dentro de um mesmo arquivo… Me julgue

Basicamente a classe Identifier representa um valor "tipado" (int, double, bool, string ou none) usado na execução do interpretador. Ela encapsula o tipo da variável (DataTypes) - futuramente do retorno de uma função - e o seu valor, além de fornecer métodos utilitários para conversão entre tipos e criação de instâncias a partir de tokens.

Imagine a expressão int idade = 41. O resultado do Parser é:

TokenType.DataType => int (Position: 0)
TokenType.Identifier => idade (Position: 4)
TokenType.Assign => = (Position: 10)
TokenType.Number => 41 (Position: 12)
TokenType.EndOfFile =>  (Position: 14)
Enter fullscreen mode Exit fullscreen mode

Note que essa estrutura de tokens vai resultar no seguinte Identifier no processo de análise sintática:

Identifier(dataType: DataTypes.Int, value: 41)
Enter fullscreen mode Exit fullscreen mode

Como o value é um objeto, logo, existem métodos para converter esse valor em um int, double, bool e string.

Aliás, temos um enum para isso:

Arquivo Runtime/DataTypes.cs:

public enum DataTypes
{
    Int,
    Double,
    Bool,
    String,

    None
}
Enter fullscreen mode Exit fullscreen mode

Aliás… e esse None aí hein…

Pois é...

Como citei acima, em breve teremos suporte a funções. Funções podem retornar valores. Ou não. Esse None indica justamente que aquele identificador — no caso uma função - não retorna valor.

Hoje já temos isso com o método interno print.

Falando em print, uma das mudanças importantes foi justamente extrair a invocação de métodos internos que estavam dentro da SyntaxParser para uma classe própria:

Arquivo Runtime/BuiltInFunctions.cs:

public static class BuiltInFunctions
{
    private static readonly Dictionary<string, Func<List<Identifier>, Identifier>> Functions = new()
    {
        ["sqrt"] = args => args.Count == 1
            ? Identifier.Create(DataTypes.Double, Math.Sqrt(args[0].ToDouble()))
            : throw new Exception("Invalid number of arguments for sqrt"),

        ["pow"] = args => args.Count switch
        {
            1 => Identifier.Create(DataTypes.Double, Math.Pow(args[0].ToDouble(), 2)),
            2 => Identifier.Create(DataTypes.Double, Math.Pow(args[0].ToDouble(), args[1].ToDouble())),
            _ => throw new Exception("Invalid number of arguments for pow")
        },

        ["min"] = args => args.Count == 2
            ? Identifier.Create(DataTypes.Double, Math.Min(args[0].ToDouble(), args[1].ToDouble()))
            : throw new Exception("Invalid number of arguments for min"),

        ["max"] = args => args.Count == 2
            ? Identifier.Create(DataTypes.Double, Math.Max(args[0].ToDouble(), args[1].ToDouble()))
            : throw new Exception("Invalid number of arguments for max"),

        ["round"] = args => args.Count switch
        {
            1 => Identifier.Create(DataTypes.Double, Math.Round(args[0].ToDouble())),
            2 => Identifier.Create(DataTypes.Double, Math.Round(args[0].ToDouble(), args[1].ToInt())),
            _ => throw new Exception("Invalid number of arguments for round")
        },

        ["random"] = args => args.Count switch
        {
            0 => Identifier.Create(DataTypes.Double, Random.Shared.NextDouble()),
            1 => Identifier.Create(DataTypes.Double, Random.Shared.Next(args[0].ToInt())),
            2 => Identifier.Create(DataTypes.Double, Random.Shared.Next(args[0].ToInt(), args[1].ToInt())),
            _ => throw new Exception("Invalid number of arguments for random")
        },

        ["upper"] = args => args.Count == 1
            ? Identifier.Create(DataTypes.Int, args[0].ToString().ToUpperInvariant())
            : throw new Exception("Invalid number of arguments for upper"),

        ["lower"] = args => args.Count == 1
            ? Identifier.Create(DataTypes.Int, args[0].ToString().ToLowerInvariant())
            : throw new Exception("Invalid number of arguments for lower"),

        ["len"] = args => args.Count == 1
            ? Identifier.Create(DataTypes.Int, args[0].ToString().Length)
            : throw new Exception("Invalid number of arguments for len"),

        ["replace"] = args => args.Count == 3
            ? Identifier.Create(DataTypes.String, args[0].ToString().Replace(args[1].ToString(), args[2].ToString()))
            : throw new Exception("Invalid number of arguments for replace"),

        ["substr"] = args => args.Count switch
        {
            2 => Identifier.Create(DataTypes.String, args[0].ToString().Substring(args[1].ToInt())),
            3 => Identifier.Create(DataTypes.String, args[0].ToString().Substring(args[1].ToInt(), args[2].ToInt())),
            _ => throw new Exception("Invalid number of arguments for substr")
        },

        ["print"] = args =>
        {
            if (args.Count != 1)
                throw new Exception("Invalid number of arguments for print");

            Console.WriteLine(args[0].ToString());

            return Identifier.None;
        }
    };

    public static bool Contains(string functionName)
        => Functions.ContainsKey(functionName);

    public static Identifier Invoke(string functionName, List<Identifier> args)
    {
        var result = Functions.TryGetValue(functionName, out var function)
            ? function(args)
            : throw new Exception($"Function {functionName} not found");

        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

Aqui não tem segredo. Temos um dicionário onde a chave é nome do método e o valor é um delegate que recebe uma lista de Identifier (args) e retorna um Identifier.

Cada delegate cria um Identifier baseado na característica do método.

No caso do print, só é aceito um argumento como parâmetro e não existe um retorno. É aqui onde temos o uso do None.

...

["print"] = args =>
{
    if (args.Count != 1)
        throw new Exception("Invalid number of arguments for print");

    Console.WriteLine(args[0].ToString());

    return Identifier.None;
} 
...
Enter fullscreen mode Exit fullscreen mode

Destaco aqui a tentativa de conversão do valor baseado no tipo do dado para o tipo esperado como argumento no método, além de validar a quantidade de parâmetros aceitos. Isso é feito para poder invocar os métodos do dotnet!!!


Agora sim… podemos navegar nos mares agitados da SyntaxParser.cs.

Vocês costumam sonhar com código? Eu já sonhei com essa classe… foi triste… eu entrava num método recursivo e não conseguia sair… acordei e estava com a testa no teclado e um erro de call stack na tela...

Arquivo CodeAnalysis/SyntaxParser.cs:

public class SyntaxParser(Dictionary<string, Identifier> identifiers, List<Token> tokens)
{
    private Token CurrentToken => tokens[_currentPosition];
    private int _currentPosition;

    public List<Identifier> Evaluate()
    {
        var results = new List<Identifier>();
        while (_currentPosition < tokens.Count && CurrentToken.Type != TokenType.EndOfFile)
        {
            var identifier = EvaluateExpression();
            results.Add(identifier);
        }

        return results;
    }

    private Identifier EvaluateExpression()
    {
        var left = EvaluateExpressionWithPriority();

        while (CurrentToken.Type is TokenType.Plus or TokenType.Minus)
        {
            var @operator = NextIfTokenIs(CurrentToken.Type);
            var right = EvaluateExpressionWithPriority();

            left = EvaluateOperation(left, right, @operator);
        }

        return left;
    }

    private Identifier EvaluateExpressionWithPriority()
    {
        var left = EvaluateToken();

        while (CurrentToken.Type is TokenType.Multiply or TokenType.Divide)
        {
            var @operator = NextIfTokenIs(CurrentToken.Type);
            var right = EvaluateToken();
            left = EvaluateOperation(left, right, @operator);
        }

        return left;
    }

    private Identifier EvaluateToken()
        => CurrentToken.Type switch
        {
            TokenType.DataType => EvaluateDataType(),
            TokenType.Identifier => EvaluateIdentifier(),
            TokenType.Function => EvaluateFunction(),
            TokenType.Plus or TokenType.Minus => EvaluateSignal(),
            TokenType.Number => EvaluateNumber(),
            TokenType.String => EvaluateString(),
            TokenType.Bool => EvaluateBool(),
            TokenType.OpenParenthesis => EvaluateParenthesis(),
            _ => throw new Exception($"Unexpected token in EvaluateToken: {CurrentToken.Type}")
        };

    private Identifier EvaluateDataType()
    {
        var dataTypeToken = NextIfTokenIs(TokenType.DataType);
        if (!Identifier.ContainsDataType(dataTypeToken.Value))
            throw new Exception($"Unknown type: {dataTypeToken.Value}");

        var checkNext = Peek();

        var name = NextIfTokenIs(TokenType.Identifier).Value;
        var identifier = Identifier.Default(dataTypeToken.Value);
        identifiers[name] = identifier;

        if (checkNext.Type != TokenType.Assign)
            return identifier;

        NextIfTokenIs(TokenType.Assign);
        var value = EvaluateExpression();
        identifier = value.Cast(dataTypeToken.Value);

        identifiers[name] = identifier;
        return identifier;
    }

    private Identifier EvaluateIdentifier()
    {
        var checkNext = Peek();
        if (CurrentToken.Type == TokenType.Identifier && checkNext.Type == TokenType.Assign)
            return EvaluateAssignment();

        var token = NextIfTokenIs(TokenType.Identifier);

        if (!identifiers.TryGetValue(token.Value, out var identifier))
            throw new Exception($"Unknown identifier: {token.Value}");

        return identifier;
    }

    private Identifier EvaluateFunction()
    {
        var token = NextIfTokenIs(TokenType.Function);
        NextIfTokenIs(TokenType.OpenParenthesis);

        var args = new List<Identifier>();
        if (CurrentToken.Type != TokenType.CloseParenthesis)
        {
            args.Add(EvaluateExpression());
            while (CurrentToken.Type == TokenType.Comma)
            {
                NextIfTokenIs(TokenType.Comma);
                args.Add(EvaluateExpression());
            }
        }

        NextIfTokenIs(TokenType.CloseParenthesis);
        return BuiltInFunctions.Invoke(token.Value, args);
    }

    private Identifier EvaluateSignal()
    {
        var token = NextIfTokenIs(CurrentToken.Type);
        var result = EvaluateToken();
        var value = token.Type == TokenType.Minus
            ? -result.ToDouble()
            : result.ToDouble();

        return new Identifier(result.DataType, value);
    }

    private Identifier EvaluateNumber()
    {
        var token = NextIfTokenIs(TokenType.Number);
        return Identifier.FromToken(token);
    }

    private Identifier EvaluateString()
    {
        var token = NextIfTokenIs(TokenType.String);
        return new Identifier(DataTypes.String, token.Value);
    }

    private Identifier EvaluateBool()
    {
        var token = NextIfTokenIs(TokenType.Bool);
        return new Identifier(DataTypes.Bool, token.Value);
    }

    private Identifier EvaluateParenthesis()
    {
        NextIfTokenIs(TokenType.OpenParenthesis);
        var result = EvaluateExpression();
        NextIfTokenIs(TokenType.CloseParenthesis);
        return result;
    }

    private static Identifier EvaluateOperation(Identifier left, Identifier right, Token @operator)
    {
        if (left.DataType == DataTypes.String || right.DataType == DataTypes.String)
            return EvaluateStringOperation(left, right, @operator);

        if (left.DataType == DataTypes.Double ||
            right.DataType == DataTypes.Double ||
            left.DataType == DataTypes.Int ||
            right.DataType == DataTypes.Int)
            return EvaluateNumberOperation(left, right, @operator);

        throw new Exception($"Unexpected token: {@operator.Type}");
    }

    private static Identifier EvaluateStringOperation(Identifier left, Identifier right, Token @operator)
    {
        var value = @operator.Type switch
        {
            TokenType.Plus => left.ToString() + right.ToString(),
            TokenType.Minus => Minus(left.ToString(), right.ToString()),
            TokenType.Multiply => Multiply(),
            _ => throw new Exception($"Unexpected token: {@operator.Type}")
        };

        return new Identifier(DataTypes.String, value);

        static string Minus(string @string, string value)
            => @string.Replace(value, string.Empty);

        string Multiply()
        {
            string @string;
            int count;

            if (left.DataType == DataTypes.String)
            {
                @string = left.ToString();
                count = right.ToInt();
            }
            else
            {
                @string = right.ToString();
                count = left.ToInt();
            }

            var result = new StringBuilder();
            for (var i = 0; i < count; i++)
                result.Append(@string);

            return result.ToString();
        }
    }

    private static Identifier EvaluateNumberOperation(Identifier left, Identifier right,
        Token @operator)
    {
        var value = @operator.Type switch
        {
            TokenType.Plus => left.ToDouble() + right.ToDouble(),
            TokenType.Minus => left.ToDouble() - right.ToDouble(),
            TokenType.Multiply => left.ToDouble() * right.ToDouble(),
            TokenType.Divide => left.ToDouble() / right.ToDouble(),
            _ => throw new Exception($"Unexpected token: {@operator.Type}")
        };

        return new Identifier(DataTypes.Double, value);
    }

    private Identifier EvaluateAssignment()
    {
        var variableName = NextIfTokenIs(TokenType.Identifier).Value;
        NextIfTokenIs(TokenType.Assign);
        var value = EvaluateExpression();

        if (!identifiers.ContainsKey(variableName))
            throw new Exception($"Unknown identifier: {variableName}");

        identifiers[variableName] = value;
        return value;
    }

    private Token Peek()
    {
        var nextPosition = _currentPosition + 1;
        return nextPosition < tokens.Count ? tokens[nextPosition] : tokens.Last();
    }

    private Token NextIfTokenIs(TokenType type)
    {
        if (CurrentToken.Type != type)
            throw new Exception($"Unexpected token: {CurrentToken.Type}, expected: {type}");

        var token = CurrentToken;
        _currentPosition++;
        return token;
    }
}
Enter fullscreen mode Exit fullscreen mode

Como citei acima, refatorei o código para poder suportar os novos tokens e o resultado é um código um pouco mais legível.

Ainda temos os métodos EvaluateExpression e EvaluateExpressionWithPriority e eles seguem fazendo a mesma coisa explicada na parte 2.

Já o método NextIfTokenIs retorna o CurrentToken. Fiz isso para facilitar o uso do método.

Foi criado o método Peek para "olhar" o próximo token em questão. Esse método vai ser muito útil em cenários como quando preciso saber se existe uma atribuição de valor a uma variável no momento da sua declaração. Nesse caso, se existir o token Assing (=) na declaração, significa que é necessário fazer todo o processo de parser dessa expressão já que é possível passar uma constante ou uma expressão. Exemplos:

int idade: nesse caso o Peek() vai indicar fim da expressão.
int valor = pow(3+5): nesse caso o Peek() vai indicar que existe um = e o processo vai precisar efetuar o evaluate dos próximos tokens.

O método EvaluateToken evoluiu para direcionar para o método de evaluate atrelado ao tipo do CurrentToken (se for Number chama o EvaluateNumber, se for String chama o EvaluateString e por aí vai…)

Acredito que somente alguns novos métodos devam ser explicados, já que eles dão suporte a nossa nova feature de declaração de variáveis. O resto é mais do mesmo e já foi explicado na parte 2.

Vou começar pelo EvaluateDataType. Esse método é onde fazemos a "declaração da variável". E começamos pelo tipo. Só que aqui temos uma coisa interessante.

Na nossa linguagem de programação, uma variável já nasce com um valor default, isto é, mesmo que a gente não informe o valor no momento da declaração, ela vai receber um valor default baseado no seu tipo.

Entendo que isso não seja comum nas linguagens de programação, já que na teoria estaríamos alocando um valor em memória, porém, os compiladores podem remover variáveis declaradas e não usadas. Eu não me preocupei com isso, foquei em deixar as coisas simples e fáceis para usar…

Lá na classe Runtime/Identifier.cs temos um método chamado Default que deixei para explicar agora propositalmente:

public static Identifier Default(string typeName)
    => typeName switch
    {
        IntType => new Identifier(DataTypes.Int, 0),
        DoubleType => new Identifier(DataTypes.Double, 0),
        BoolType => new Identifier(DataTypes.Bool, false),
        StringType => new Identifier(DataTypes.String, string.Empty),
        _ => throw new Exception($"Invalid data type: {typeName}")
    };
Enter fullscreen mode Exit fullscreen mode

Caso na declaração um = for encontrado (e é aqui que usamos o Peek()) atribuímos o valor da expressão seguinte. Caso contrário, atribuímos o valor default. Nada de nulo. Isso é por definição da linguagem. E nada de forçar a atribuição de valores antes de usar a variável (algo que poderia ter no csharp né?). Isso deixa a linguagem mais clean... Virei designer... de linguagens... vou até atualizar o meu Curriculum.

Ao obter o Identifier da variável, armazenamos o objeto num dicionário onde a chave é o nome da variável e o valor é o próprio Identifier.

E aqui temos uma coisa um pouco estranha e um pouco interessante: para manter o valor da variável em memória, é preciso manter esse dicionário em memória e repassá-lo toda vez que iniciarmos o processo de análise sintática. Logo, em nosso Program.cs esse dicionário é criado e repassado no construtor do SyntaxParser.

É por isso que podemos executar expressões do tipo:

> int idade = random(1, 50)
27
> string nome = "Angelo"
Angelo
> print(nome + " tem " + idade + " anos de idade")
Angelo tem 27 anos de idade
Enter fullscreen mode Exit fullscreen mode

Cada linha de execução (que começa com >) é uma expressão. E a cada expressão, uma instância do SyntaxParser é criada.

Lembrando que esse processo existe somente para suportar o REPL. Aliás, se eu passar o conteúdo todo de uma vez, o nosso compilador vai funcionar normalmente. Em breve, vou adicionar um projeto onde teremos um editor de código baseado no VSCode, o famigerado Monaco Editor. Dessa forma, nós vamos poder escrever código normalmente, como se estivéssemos usando um "IDE"… no caso estaremos… não é "aquela" "IDE", mas é uma "IDE modesta e que nos atende bem…

Na boa, não vou entrar na discussão "IDE" x "Editor de Código"...

Enfim… é estranho… mas é necessário…

Já o método EvaluateAssignment somente atribui valor a uma variável já declarada. Algo como idade = 28.

Esses métodos por si só suportam nossa feature de criação de variáveis. Porém, temos mais algumas coisinhas legais.

EvaluateOperation! Podemos efetuar operações usando +, -, * e / com números e com… string. Bom… você já presenciou isso em print(nome + " tem " + idade + " anos de idade"). Basicamente, estou somando string com int.

Esse método valida se ambos Identifiers recebidos são de tipos numéricos, (se esquerda e direita são do Double ou Int — isso num fluxo de operação, algo como idade + 1). Se sim, invoca o método EvaluateNumberOperation, que faz o cálculo matemático trivial de soma, subtração, divisão e multiplicação. Nada de novo no front...

Porém, caso algum dos Identifiers seja uma string vamos poder fazer manipulações usando os operadores - e *, além do +, claro.

Se o operador for +, concatena a string:

> "Angelo" + " Belchior"
Angelo Belchior
Enter fullscreen mode Exit fullscreen mode

Até aqui, beleza.

E se o operador for -? Eu decidi, como designer de linguagens que agora sou, que nesse caso, eu removo um conteúdo da string. Exemplo:

> "Angelo Belchior Br" - " Belchior "
AngeloBr
Enter fullscreen mode Exit fullscreen mode

Dada a string "Angelo Belchior Br", ao "subtrair" o texto " Belchior " temos como resultado "AngeloBr". Legal, né?

Já o operador * basicamente multiplica (repete x vezes) a string em questão. Por exemplo:

> "A" * 3
AAA
Enter fullscreen mode Exit fullscreen mode

Um ponto importante aqui é que, como tratamos dois Identifiers, o da esquerda e o da direita da expressão, precisamos validar qual é o valor numérico e qual é o valor alfanumérico justamente para podermos executar expressões do tipo "A" * 3 e 3 * "A" e obtermos o valor AAA.

No futuro, caso tenhamos suporte a arrays podemos implementar o operador / para expressões do tipo "A;B;C" / ";" e obtermos ["A", "B", "C"]. Quem sabe...


Lexer e SyntaxParser explicados. Nova estrutura do projeto apresentada. O que resta agora é mostrar como ficou nosso Program.cs

Arquivo Program.cs:

Dictionary<string, Identifier> identifiers = new();

var printTokens = false;

while (true)
{
    try
    {
        Console.ForegroundColor = ConsoleColor.Green;
        Console.Write("> ");

        var line = Console.ReadLine() ?? string.Empty;

        Console.ResetColor();

        if (line.Equals("/quit", StringComparison.CurrentCultureIgnoreCase))
            return;

        if (line.Equals("/cls", StringComparison.CurrentCultureIgnoreCase))
        {
            Console.Clear();
            continue;
        }

        if (line.Equals(":t", StringComparison.CurrentCultureIgnoreCase))
        {
            printTokens = !printTokens;
            Console.WriteLine($"Print tokens: {printTokens}");
            continue;
        }

        if (string.IsNullOrEmpty(line))
            break;

        var lexer = new Lexer(line);
        var tokens = lexer.ExtractTokens();

        if (printTokens)
            PrintTokens(tokens);

        var syntaxParser = new SyntaxParser(identifiers, tokens);
        var results = syntaxParser.Evaluate();

        foreach (var result in results)
            if (result.DataType != DataTypes.None)
                WriteLine(result.Value, ConsoleColor.Blue);
    }
    catch (Exception ex)
    {
        WriteLine(ex.Message, ConsoleColor.Red);
    }
}

static void WriteLine(object message, ConsoleColor color)
{
    Console.ForegroundColor = color;
    Console.WriteLine(message);
    Console.ResetColor();
}

static void PrintTokens(IEnumerable<Token> tokens)
{
    foreach (var token in tokens)
        Console.WriteLine(token);
}
Enter fullscreen mode Exit fullscreen mode

Firulas… apenas firulas… Uma corzinha aqui, outra ali…

As duas únicas novidades que devemos citar são a criação do dicionário de Identifiers (explicado acima o motivo para ter essa variável) e o resultado do syntaxParser.Evaluate que agora vem em uma lista e efetuamos o print de cada um deles. O resto é perfumaria…

Código Fonte: https://github.com/angelobelchior/Pug.Compiler


Mais uma feature entregue. Seguimos avançando. Próximo post promete!

Forte abraço e até lá!

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.