Ir para o conteúdo

5. Analisador sintático

Com o JackTokenizer completo, chegamos à etapa central do front-end: o analisador sintático, ou CompilationEngine. Ele consome a sequência de tokens do capítulo anterior e reconhece a estrutura da gramática de Jack (capítulo 3) — por enquanto, apenas reconhecendo, sem gerar nenhum código ainda (isso fica para os capítulos 9 e 10, na Parte 2). A saída deste capítulo é uma árvore sintática em XML, que — assim como fizemos com os tokens — vamos validar por comparação byte a byte com arquivos de referência do próprio Nand2Tetris.

O que significa "analisar"

Antes de qualquer código, vale fixar o que o parser faz, com uma gramática pequena o bastante para caber na cabeça — um recorte de Jack, só com comandos e expressões simples:

statements     -> statement*
statement      -> ifStatement | whileStatement | letStatement
ifStatement    -> 'if' '(' expression ')' '{' statements '}'
whileStatement -> 'while' '(' expression ')' '{' statements '}'
letStatement   -> 'let' varName '=' expression ';'
expression     -> term (op term)?
term           -> varName | integerConstant
op             -> '+' | '-' | '=' | '>' | '<'

Essas oito regras já descrevem infinitos programas. Todas as entradas abaixo pertencem à linguagem — cada uma pode ser obtida aplicando as regras, a partir de statements:

let x = 100;

let x = x + 1;

while (n < lim) {
    let n = n + 1;
}

while (lim < 100) {
    if (x = 1) {
        let z = 100;
        while (z > 0) {
            let z = z - 1;
        }
    }
    let lim = lim + 10;
}

E estas não pertencem: let x = 1 + 2 + 3; (nesta gramática, uma expressão tem no máximo um operador), while x < 3 { } (faltam os parênteses) e let = 5; (falta o varName).

Analisar (parse) uma entrada é fazer duas coisas ao mesmo tempo:

  1. decidir se ela pertence à linguagem da gramática;
  2. se pertencer, descobrir sua estrutura: que regra gerou cada pedaço.

A segunda parte é a que interessa ao compilador. Saber que let n = n + 1; está dentro do corpo do while, e que n + 1 é a expressão do let, é exatamente a informação de que a geração de código vai precisar na Parte 2.

A árvore sintática

A estrutura descoberta pelo parser é uma árvore: cada nó interno é uma regra (um não-terminal), e as folhas são os tokens, na ordem em que aparecem na entrada. Para while (count < 100) { let count = count + 1; }:

whileStatement
├── while
├── (
├── expression
│   ├── term ── count
│   ├── <
│   └── term ── 100
├── )
├── {
├── statements
│   └── letStatement
│       ├── let
│       ├── count
│       ├── =
│       ├── expression
│       │   ├── term ── count
│       │   ├── +
│       │   └── term ── 1
│       └── ;
└── }

Lendo as folhas da esquerda para a direita, recuperamos a entrada original. Lendo os nós internos, recuperamos por que cada token está onde está.

Desenhar a árvore numa tela não é prático para testar um compilador, mas há um formato de texto que representa árvores naturalmente: o XML. Cada nó interno vira um par de tags, e cada folha, uma linha com o tipo do token. É a mesma árvore, só que serializada — e é exatamente o que o nosso parser vai produzir (a indentação é só para leitura):

<whileStatement>
  <keyword> while </keyword>
  <symbol> ( </symbol>
  <expression>
    <term>
      <identifier> count </identifier>
    </term>
    <symbol> &lt; </symbol>
    <term>
      <integerConstant> 100 </integerConstant>
    </term>
  </expression>
  <symbol> ) </symbol>
  <symbol> { </symbol>
  <statements>
    <letStatement>
      <keyword> let </keyword>
      <identifier> count </identifier>
      <symbol> = </symbol>
      <expression>
        <term>
          <identifier> count </identifier>
        </term>
        <symbol> + </symbol>
        <term>
          <integerConstant> 1 </integerConstant>
        </term>
      </expression>
      <symbol> ; </symbol>
    </letStatement>
  </statements>
  <symbol> } </symbol>
</whileStatement>

(O < da comparação aparece como &lt;, pelo escape de XML visto no capítulo 4.) Por isso o Nand2Tetris usa XML como saída do Project 10: se o nosso XML bate com o de referência, o parser descobriu a estrutura certa — antes de gerarmos uma única instrução.

Da gramática ao código: um método por regra

O parser que vamos construir é um conjunto de métodos, um por regra, e o corpo de cada método é a tradução mecânica do lado direito da regra. A correspondência é sempre a mesma:

Na regra No método
um terminal ('while', '(', ';') consome o token, conferindo que é o esperado (expectPeek)
um não-terminal (expression, statements) chama o método dele (parseExpression(), parseStatements())
x? (opcional) if: o próximo token indica se x está presente
x* (zero ou mais) while: repete enquanto o próximo token indicar mais um x
a | b | c (alternativas) switch/if sobre o próximo token, que diz qual alternativa seguir

Aplicada a whileStatement -> 'while' '(' expression ')' '{' statements '}', a tabela dá, linha a linha (em pseudocódigo):

parseWhile():
    abre <whileStatement>
    consome 'while'
    consome '('
    parseExpression()
    consome ')'
    consome '{'
    parseStatements()
    consome '}'
    fecha </whileStatement>

Cada método é responsável só pela sua parte da entrada: consome os tokens da sua regra e delega o resto a quem sabe tratá-lo.

O parser em ação

Vale acompanhar, passo a passo, o que acontece quando esse parser lê while (count < 100) { let count = count + 1; }. À esquerda, os métodos ativos (a indentação mostra quem chamou quem); à direita, o XML produzido naquele passo:

parseWhile                         <whileStatement>
  consome 'while'                    <keyword> while </keyword>
  consome '('                        <symbol> ( </symbol>
  parseExpression                    <expression>
    parseTerm                          <term>
      consome 'count'                    <identifier> count </identifier>
    (fim de parseTerm)                 </term>
    consome '<'                        <symbol> &lt; </symbol>
    parseTerm                          <term>
      consome '100'                      <integerConstant> 100 </integerConstant>
    (fim de parseTerm)                 </term>
  (fim de parseExpression)           </expression>
  consome ')'                        <symbol> ) </symbol>
  consome '{'                        <symbol> { </symbol>
  parseStatements                    <statements>
    parseLet                           <letStatement>
      consome 'let'                      <keyword> let </keyword>
      consome 'count'                    <identifier> count </identifier>
      consome '='                        <symbol> = </symbol>
      parseExpression                    <expression>
        parseTerm ...                      <term> ... </term>
        consome '+'                        <symbol> + </symbol>
        parseTerm ...                      <term> ... </term>
      (fim de parseExpression)           </expression>
      consome ';'                        <symbol> ; </symbol>
    (fim de parseLet)                  </letStatement>
  (fim de parseStatements)           </statements>
  consome '}'                        <symbol> } </symbol>
(fim de parseWhile)                </whileStatement>

Três coisas para observar:

  • A pilha de chamadas desenha a árvore. Cada método ativo é um nó interno da árvore da seção anterior; quando ele termina, o nó está completo. O parser nunca guarda a árvore na memória: ela existe só no formato da execução recursiva, e é escrita no XML enquanto acontece.
  • Cada decisão olha um token à frente. parseStatements só chama parseLet porque o próximo token é let; parseExpression só consome o < porque o próximo token é um operador; e sabe que acabou quando vê ).
  • Não há volta atrás. Nenhum token é lido duas vezes, nenhum caminho é tentado e desfeito. Isso só é possível porque a gramática foi escrita para isso — é o assunto da próxima seção.

Bottom-up ou top-down?

Existem duas famílias opostas de algoritmo para construir uma árvore sintática a partir de uma cadeia de tokens:

  • Parsers bottom-up: começam pelas folhas e fazem a árvore crescer em direção à raiz, identificando a cada passo uma substring da entrada que corresponde ao lado direito de alguma produção.
  • Parsers top-down: começam pela raiz (o não-terminal inicial) e fazem a árvore crescer em direção às folhas, expandindo a cada passo um não-terminal pendente com uma de suas produções.

Neste livro usamos top-down, na sua forma mais direta de implementar à mão: o analisador preditivo recursivo, que já introduzimos no capítulo 2. A eficiência (e a própria viabilidade) de um parser top-down depende de escolher a produção certa a cada passo, olhando apenas o token atual — sem backtracking. Uma gramática que permite essa escolha univocamente com um único símbolo de antecipação (lookahead) é chamada LL(1): entrada lida da esquerda para a direita (Left-to-right), construindo a derivação mais à esquerda (Leftmost), com exatamente 1 símbolo de antecipação. Gramáticas LL(1) também são chamadas preditivas, e é exatamente esse o cuidado que tivemos, no capítulo 1, ao eliminar recursão à esquerda e fatorar a gramática: sem essas duas propriedades, um parser recursivo simplesmente não funciona.

A gramática de Jack do capítulo 3 é quase LL(1): não tem recursão à esquerda, e em quase todo não-terminal o primeiro token basta para escolher a produção (um comando que começa com while só pode ser um whileStatement, por exemplo). A única exceção está em term (e, pelo mesmo motivo, em subroutineCall): as alternativas varName, varName '[' expression ']' e as duas formas de subroutineCall começam todas com um identifier, e só o token seguinte — [, (, . ou nenhum deles — diz qual delas se aplica. Nesse ponto, e apenas nele, a gramática como está escrita é LL(2).

Não precisamos reescrever a gramática para lidar com isso. Basta aplicar, no código, a fatoração à esquerda do capítulo 1: o parser consome o identifier comum a todas as alternativas e só então decide, olhando o próximo token. Com essa fatoração implícita, cada decisão do parser é tomada com um único token de antecipação. A técnica de tradução é a mesma do capítulo 2: um método por não-terminal, mapeando cada produção diretamente em código.

Tratamento de erros sintáticos

Diferente de erros léxicos (tipicamente erros de digitação — um símbolo que não existe na linguagem), erros sintáticos dizem respeito à estrutura: um ; esquecido, um let sem =. Nossa estratégia de tratamento é deliberadamente simples: no primeiro erro encontrado, o parser para e emite uma mensagem no formato Linha N: Esperado 'X' encontrado 'Y'. Não faremos recuperação de erros nem tentaremos continuar a análise após o primeiro problema — isso é suficiente para os objetivos didáticos deste curso. (Também não implementaremos análise semântica neste analisador. A análise semântica deste livro se resume à tabela de símbolos do capítulo 8, que descobre o que cada identificador é, mas não verifica tipos: somar um int com um boolean não é detectado em nenhum ponto do compilador.)

Estrutura básica: um token de antecipação

Boa parte da gramática de Jack pode ser decidida olhando apenas o token atual. Mas uma parte — notavelmente, distinguir uma variável simples de um acesso a array ou de uma chamada de subroutine dentro de term — exige olhar um token à frente. Por isso, de forma simétrica ao peekNext() do tokenizador, o parser mantém dois tokens em memória: o token atual (currentToken) e o próximo (peekToken):

public class Parser {
    private static class ParseError extends RuntimeException {}

    private Scanner scan;
    private Token currentToken;
    private Token peekToken;
    private StringBuilder xmlOutput = new StringBuilder();

    public Parser(byte[] input) {
        scan = new Scanner(input);
        nextToken();   // primeira chamada: currentToken fica null, peekToken recebe o 1º token
        nextToken();   // segunda chamada: currentToken recebe o 1º, peekToken recebe o 2º
    }

    private void nextToken() {
        currentToken = peekToken;
        peekToken = scan.nextToken();
    }
}

Três funções auxiliares organizam toda a interação com os tokens — e vão aparecer em praticamente todo método deste capítulo:

boolean peekTokenIs(TokenType type) {
    return peekToken.type == type;
}

private void expectPeek(TokenType type) {
    if (peekToken.type == type) {
        nextToken();
        xmlOutput.append(currentToken.toString()).append("\r\n");
    } else {
        throw error(peekToken, "Esperado " + type.name());
    }
}

private void expectPeek(TokenType... types) {
    for (TokenType type : types) {
        if (peekToken.type == type) {
            expectPeek(type);
            return;
        }
    }
    throw error(peekToken, "Esperado um dos tipos: " + Arrays.toString(types));
}

expectPeek é o equivalente, neste parser, do match() do capítulo 2 — mas com uma diferença de nome que reflete a diferença de estrutura: como mantemos peekToken, "casar" com um símbolo terminal significa verificar que o próximo token é do tipo esperado, avançar, e (novidade) registrar esse token na saída XML. Um método auxiliar printNonTerminal(nome) grava as tags de abertura/fechamento de cada não-terminal (<term>, </term>, etc.) — é o que torna a árvore sintática visível na saída. Completam o esqueleto o tratamento de erro, o ponto de entrada e o acesso à saída:

private ParseError error(Token token, String message) {
    String encontrado = token.type == TokenType.EOF ? "fim do arquivo" : token.lexeme;
    System.err.println("Linha " + token.line + ": " + message + " encontrado '" + encontrado + "'");
    return new ParseError();
}

private void printNonTerminal(String nterminal) {
    xmlOutput.append("<").append(nterminal).append(">\r\n");   // "/term" gera </term>
}

public void parse() {
    parseClass();       // o símbolo inicial da gramática
}

public String XMLOutput() {
    return xmlOutput.toString();
}

error usa o número de linha que o tokenizador do capítulo 4 guarda em cada Token, produzindo mensagens como Linha 12: Esperado SEMICOLON encontrado 'let'. Ela devolve a exceção em vez de lançá-la, para que quem chama escreva throw error(...) — assim o compilador Java sabe que aquele ponto do código não continua.

Term — começando pelo caso simples

A gramática de term (capítulo 3, Apêndice A) tem oito alternativas. Vamos começar por apenas quatro, as mais diretas:

term -> integerConstant | stringConstant | keywordConstant | varName
void parseTerm() {
    printNonTerminal("term");
    switch (peekToken.type) {
        case NUMBER:
            expectPeek(TokenType.NUMBER);
            break;
        case STRING:
            expectPeek(TokenType.STRING);
            break;
        case FALSE: case NULL: case TRUE:
            expectPeek(TokenType.FALSE, TokenType.NULL, TokenType.TRUE);
            break;
        case THIS:
            expectPeek(TokenType.THIS);
            break;
        case IDENT:
            expectPeek(TokenType.IDENT);
            break;
        default:
            throw error(peekToken, "term esperado");
    }
    printNonTerminal("/term");
}

Testando isoladamente com a entrada "10;", chamando parser.parseTerm() diretamente (sem passar por parse()), a saída XML deve ser:

<term>
<integerConstant> 10 </integerConstant>
</term>

Esse hábito — testar cada não-terminal isoladamente, chamando o método de parse diretamente com uma entrada mínima — é o que torna viável construir um parser desse tamanho de forma incremental: cada método pode ser validado no mesmo instante em que é escrito, sem depender do resto da gramática já estar pronto.

Expressão: term seguido de zero ou mais (op term)

expression -> term (op term)*
static boolean isOperator(String op) {
    return !op.isEmpty() && "+-*/<>=&|".contains(op);
}

void parseExpression() {
    printNonTerminal("expression");
    parseTerm();
    while (isOperator(peekToken.lexeme)) {
        expectPeek(peekToken.type);
        parseTerm();
    }
    printNonTerminal("/expression");
}

Repare como a ausência de precedência de operadores em Jack (capítulo 3) se paga exatamente aqui: não precisamos de uma hierarquia expression → term → factor → ... para codificar níveis de precedência — um único laço while já captura toda a gramática de expressões. Testando com "10+20":

<expression>
<term>
<integerConstant> 10 </integerConstant>
</term>
<symbol> + </symbol>
<term>
<integerConstant> 20 </integerConstant>
</term>
</expression>

O comando let

letStatement -> 'let' varName ('[' expression ']')? '=' expression ';'
void parseLet() {
    printNonTerminal("letStatement");
    expectPeek(TokenType.LET);
    expectPeek(TokenType.IDENT);

    if (peekTokenIs(TokenType.LBRACKET)) {
        expectPeek(TokenType.LBRACKET);
        parseExpression();
        expectPeek(TokenType.RBRACKET);
    }

    expectPeek(TokenType.EQ);
    parseExpression();
    expectPeek(TokenType.SEMICOLON);
    printNonTerminal("/letStatement");
}

O if (peekTokenIs(LBRACKET)) é a tradução direta do ? (zero ou uma vez) da gramática — o mesmo padrão que vamos repetir para toda parte opcional das demais regras (o else de ifStatement, o expression? de returnStatement, etc.).

Chamada de subroutine e o comando do

Uma chamada de subroutine é o primeiro lugar onde o lookahead de um token realmente importa: ao ver um identificador, ainda não sabemos se é uma chamada direta (hello()) ou qualificada por classe/objeto (Classe.metodo() ou objeto.metodo()) — só o próximo token (( ou .) resolve a ambiguidade. Começamos pelo caso mais simples, deixando a forma qualificada como extensão natural do mesmo método:

subroutineCall -> subroutineName '(' expressionList ')'
                 | (className|varName) '.' subroutineName '(' expressionList ')'
// versão inicial: apenas identifier '(' ')'
void parseSubroutineCall() {
    expectPeek(TokenType.IDENT);
    expectPeek(TokenType.LPAREN);
    expectPeek(TokenType.RPAREN);
}

E o comando do, que apenas envolve uma chamada de subroutine com a palavra-chave e o ; final:

doStatement -> 'do' subroutineCall ';'
void parseDo() {
    printNonTerminal("doStatement");
    expectPeek(TokenType.DO);
    parseSubroutineCall();
    expectPeek(TokenType.SEMICOLON);
    printNonTerminal("/doStatement");
}

Testando com "do hello();":

<doStatement>
<keyword> do </keyword>
<identifier> hello </identifier>
<symbol> ( </symbol>
<symbol> ) </symbol>
<symbol> ; </symbol>
</doStatement>

O identificador vem primeiro

A versão inicial de parseSubroutineCall consome o identificador por conta própria. Isso funciona no do, mas não dentro de term. Lá, ao ver um identifier, o parser ainda não sabe se está diante de uma variável (x), de um acesso a array (a[i]) ou de uma chamada (f(), Classe.f(), obj.f()) — e só descobre olhando o token seguinte ao identificador. É o trecho LL(2) da gramática, discutido no início do capítulo.

A solução é a fatoração à esquerda: parseTerm consome o identificador comum a todas as alternativas e só então decide, olhando o peekToken. Para isso, parseSubroutineCall passa a supor que o identificador inicial já foi consumido (é o currentToken):

// pré-condição: o identificador inicial já é o currentToken
void parseSubroutineCall() {
    if (peekTokenIs(TokenType.LPAREN)) {           // subroutineName '(' expressionList ')'
        expectPeek(TokenType.LPAREN);
        parseExpressionList();
        expectPeek(TokenType.RPAREN);
    } else {                                       // (className | varName) '.' subroutineName '(' ... ')'
        expectPeek(TokenType.DOT);
        expectPeek(TokenType.IDENT);
        expectPeek(TokenType.LPAREN);
        parseExpressionList();
        expectPeek(TokenType.RPAREN);
    }
}

O caso IDENT de parseTerm fica:

case IDENT:
    expectPeek(TokenType.IDENT);                  // consome o identificador comum
    if (peekTokenIs(TokenType.LPAREN) || peekTokenIs(TokenType.DOT)) {
        parseSubroutineCall();                    // chamada
    } else if (peekTokenIs(TokenType.LBRACKET)) {
        expectPeek(TokenType.LBRACKET);           // acesso a array
        parseExpression();
        expectPeek(TokenType.RBRACKET);
    }
    // nenhum dos dois: varName simples, nada mais a consumir
    break;

e parseDo passa a consumir o identificador antes de chamar parseSubroutineCall:

void parseDo() {
    printNonTerminal("doStatement");
    expectPeek(TokenType.DO);
    expectPeek(TokenType.IDENT);
    parseSubroutineCall();
    expectPeek(TokenType.SEMICOLON);
    printNonTerminal("/doStatement");
}

A saída XML de "do hello();" não muda. É essa forma — identificador consumido antes de parseSubroutineCall — que os capítulos 9 e 10 vão estender com a geração de código.

Quais não-terminais viram tags

Para que a saída bata com os arquivos de referência, nem todo não-terminal da gramática gera um par de tags. A especificação do Nand2Tetris define exatamente quais geram:

Geram <tag> ... </tag> Não geram tag
class, classVarDec, subroutineDec, parameterList, subroutineBody, varDec type, className, subroutineName, varName — aparecem direto como <keyword> ou <identifier>
statements, letStatement, ifStatement, whileStatement, doStatement, returnStatement statement — parseStatements chama parseLet, parseIf etc. diretamente; não existe parseStatement
expression, term, expressionList subroutineCall — seus tokens aparecem dentro de <doStatement> ou de <term>, por isso parseSubroutineCall não chama printNonTerminal

Dois detalhes costumam quebrar a comparação: <parameterList> e <expressionList> aparecem mesmo quando a lista está vazia (um par de tags sem nada dentro), e parseStatements emite <statements> mesmo para um bloco { } vazio.

Fechando o restante da gramática

A partir daqui, o padrão está estabelecido — cada não-terminal restante segue exatamente a mesma receita (um método, um expectPeek por terminal, uma chamada recursiva por não-terminal, um if/while por ?/* da gramática). Fica como exercício guiado completar:

  • parseTerm — os dois casos que faltam: expressão entre parênteses ('(' expression ')') e operador unário (unaryOp term). Ambos se decidem pelo primeiro token ((, ou -/~), sem antecipação extra.
  • parseExpressionList, parseIf, parseWhile, parseReturn, parseStatements — cada um uma tradução direta de sua produção na tabela do capítulo 3.
  • parseClassVarDec, parseSubroutineDec, parseParameterList, parseVarDec, parseSubroutineBody, parseClass — a "casca" da classe, fechando o topo da gramática.

A API completa esperada do CompilationEngine (ou Parser) é:

Rotina Compila
parseClass Uma classe completa
parseClassVarDec Uma declaração static ou field
parseSubroutineDec Um método, função ou construtor completo
parseSubroutineBody O corpo de uma subroutine: declarações var e comandos, com as chaves
parseParameterList Uma lista de parâmetros (possivelmente vazia), sem os parênteses
parseVarDec Uma declaração var
parseStatements Uma sequência de comandos, sem as chaves
parseDo / parseLet / parseIf / parseWhile / parseReturn Cada tipo de comando
parseExpression / parseTerm / parseExpressionList Expressões e suas partes
parseSubroutineCall Uma chamada de subroutine (auxiliar, sem tag própria; supõe o identificador já consumido)

Validando contra a árvore sintática de referência

Assim como no capítulo 4, o Nand2Tetris fornece, para cada programa Jack de exemplo, um arquivo XML de referência — desta vez com a árvore sintática completa (não apenas os tokens). Há uma diferença em relação aos arquivos de tokens: os *T.xml do capítulo 4 não têm indentação, mas os arquivos de árvore sintática (Square.xml etc.) são indentados, dois espaços por nível — e o nosso printNonTerminal não indenta. A indentação serve só para leitura (o TextComparer do Nand2Tetris a ignora), então o teste compara as duas saídas linha a linha, depois de remover espaços nas pontas e linhas vazias:

private static String normalize(String xml) {
    return xml.lines()
              .map(String::strip)
              .filter(l -> !l.isEmpty())
              .collect(Collectors.joining("\n"));
}

@Test
public void testParserWithSquare() throws IOException {
    var input = fromFile("Square/Square.jack");
    var expectedResult = fromFile("Square/Square.xml");

    var parser = new Parser(input.getBytes(StandardCharsets.UTF_8));
    parser.parse();
    var result = parser.XMLOutput();
    assertEquals(normalize(expectedResult), normalize(result));
}

O Project 10 traz três programas de teste, que sugerem uma ordem de construção em duas versões do parser:

Programa O que testa Quando usar
ExpressionLessSquare a mesma classe Square do jogo, mas com toda expressão trocada por um único termo simples (let size = size;, if (x) { ... }) versão básica: só parseTerm simples, sem operadores, e toda a "casca" da gramática — classes, subroutines e comandos
Square o jogo completo, com as expressões de verdade versão completa: expressões com operadores, chamadas de subroutine dentro de term
ArrayTest um programa que lê números para um array e calcula a média acesso a array (a[i]) dos dois lados do = e chamadas com strings como argumento

O código de ExpressionLessSquare não faz sentido (let size = size; não muda nada), mas é gramaticalmente válido — e é isso que importa para testar o parser: a estrutura de classes e comandos fica validada antes de enfrentarmos a parte mais delicada, a gramática de expressões.

Dica de processo

Não tente compilar de uma vez um arquivo .jack inteiro contra a gramática completa. Construa o parser exatamente na ordem que fomos construindo aqui: primeiro term simples, depois expression, depois cada comando isoladamente (com testes próprios, como fizemos com parseLet e parseDo), só então subindo para parseStatements, parseSubroutineDec e finalmente parseClass. É a mesma disciplina de "um passo por vez, com teste a cada passo" que usamos desde o capítulo 2.

O que vem a seguir

Com um parser completo (mesmo que ainda apenas reconhecendo a estrutura, sem gerar código), fechamos o front-end da Parte 1. Antes de gerar código de verdade, porém, a Parte 2 precisa definir para onde traduzir: o capítulo 6 discute representações intermediárias e o capítulo 7 especifica a VM Hack, nosso alvo. Só então faz sentido a última peça — a tabela de símbolos (capítulo 8), que diz onde cada identificador vive (é uma variável local? um campo do objeto? um parâmetro?) e que tipo ele tem — e que finalmente nos permite evoluir este reconhecedor em um compilador que produz código VM de verdade.