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)
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
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
}
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 umtoken
do tipoNumber
com valor 123. -
"Olá Mundo"
gera umtoken
do tipoString
com valor "Olá Mundo". -
true
gera umtoken
do tipoBool
com valortrue
. -
false
gera umtoken
do tipoBool
com valorfalse
.
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
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();
}
}
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")
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}
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
deint idade = 41
- Angelo:
evaluate
destring nome = "Angelo"
- {espaço em branco}:
evaluate
deprint(nome + " tem " + idade + " anos de idade")
. Comoprint
retorna umidentifier
cujo valor éNone
(veremos mais pra frente), nenhum resultado dessa execução é impresso noconsole
.
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;
}
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)
Note que essa estrutura de tokens
vai resultar no seguinte Identifier
no processo de análise sintática:
Identifier(dataType: DataTypes.Int, value: 41)
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
}
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;
}
}
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;
}
...
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;
}
}
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}")
};
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
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
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
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
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);
}
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.