Skip to content

Lexer

Token

O léxico (também chamado tokenizer ou scanner) transforma o texto-fonte em tokens. O parser consumirá esses tokens depois, assim não precisamos nos preocupar com espaços em branco e comentários do texto original no estágio seguinte.

Comecemos simples e transformemos apenas o texto + em um token.

rust
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Token {
    /// Token Type
    pub kind: Kind,

    /// Start offset in source
    pub start: usize,

    /// End offset in source
    pub end: usize,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Kind {
    Eof, // end of file
    Plus,
}

Um único + produz:

[
    Token { kind: Kind::Plus, start: 0, end: 1 },
    Token { kind: Kind::Eof,  start: 1, end: 1 }
]

Para percorrer a string, dá para manter um índice e fingir que estamos em C, ou dar uma olhada na documentação de str e trabalhar com o iterador Chars.

INFO

O iterador Chars esconde o índice e o boundary checking — sensação bem “segura”.

Ele nos dá Option<char> em chars.next(). Mas atenção: char não é um ASCII 0–255; é um ponto Unicode UTF-8 com faixa até 0x10FFFF.

Definimos um léxico bem inicial:

rust
use std::str::Chars;

struct Lexer<'a> {
    /// Source Text
    source: &'a str,

    /// The remaining characters
    chars: Chars<'a>
}

impl<'a> Lexer<'a> {
    pub fn new(source: &'a str) -> Self {
        Self {
            source,
            chars: source.chars()
        }
    }
}

INFO

O tempo de vida 'a aqui diz que o iterador referencia algum lugar — neste caso, &'a str.

Para converter texto em tokens, basta repetir chars.next() e fazer match em cada char. O último token será sempre Kind::Eof.

rust
impl<'a> Lexer<'a> {
    fn read_next_kind(&mut self) -> Kind {
        while let Some(c) = self.chars.next() {
            match c {
              '+' => return Kind::Plus,
              _ => {}
            }
        }
        Kind::Eof
    }

    fn read_next_token(&mut self) -> Token {
        let start = self.offset();
        let kind = self.read_next_kind();
        let end = self.offset();
        Token { kind, start, end }
    }

    /// Get the length offset from the source text, in UTF-8 bytes
    fn offset(&self) -> usize {
        self.source.len() - self.chars.as_str().len()
    }
}

As chamadas .len() e .as_str().len() dentro de offset parecem O(n); vamos entender por quê.

.as_str() devolve um slice que aponta para a string:

rust
// https://github.com/rust-lang/rust/blob/b998821e4c51c44a9ebee395c91323c374236bbb/library/core/src/str/iter.rs#L112-L115

pub fn as_str(&self) -> &'a str {
    // SAFETY: `Chars` is only made from a str, which guarantees the iter is valid UTF-8.
    unsafe { from_utf8_unchecked(self.iter.as_slice()) }
}

Um slice é uma visão de memória (ponteiro + comprimento). O método .len() lê só os metadados do slice:

rust
// https://github.com/rust-lang/rust/blob/b998821e4c51c44a9ebee395c91323c374236bbb/library/core/src/str/mod.rs#L157-L159

pub const fn len(&self) -> usize {
    self.as_bytes().len()
}
rust
// https://github.com/rust-lang/rust/blob/b998821e4c51c44a9ebee395c91323c374236bbb/library/core/src/str/mod.rs#L323-L325

pub const fn as_bytes(&self) -> &[u8] {
    // SAFETY: const sound because we transmute two types with the same layout
    unsafe { mem::transmute(self) }
}
rust
// https://github.com/rust-lang/rust/blob/b998821e4c51c44a9ebee395c91323c374236bbb/library/core/src/slice/mod.rs#L129-L138

pub const fn len(&self) -> usize {
    // FIXME: Replace with `crate::ptr::metadata(self)` when that is const-stable.
    // As of this writing this causes a "Const-stable functions can only call other
    // const-stable functions" error.

    // SAFETY: Accessing the value from the `PtrRepr` union is safe since *const T
    // and PtrComponents<T> have the same memory layouts. Only std can make this
    // guarantee.
    unsafe { crate::ptr::PtrRepr { const_ptr: self }.components.metadata }
}

Tudo isso costuma virar um único acesso a dados, então .as_str().len() é na prática O(1).

Peek

Para tokenizar operadores multi-caractere como ++ ou +=, precisamos de um helper peek:

rust
fn peek(&self) -> Option<char> {
    self.chars.clone().next()
}

Não queremos avançar o iterador original chars, então clonamos e consumimos esse clone.

INFO

clone é barato — veja o código-fonte: copia só os índices de rastreamento e de limite.

rust
// https://github.com/rust-lang/rust/blob/b998821e4c51c44a9ebee395c91323c374236bbb/library/core/src/slice/iter.rs#L148-L152

impl<T> Clone for Iter<'_, T> {
    fn clone(&self) -> Self {
        Iter { ptr: self.ptr, end: self.end, _marker: self._marker }
    }
}

A diferença: peek sempre devolve o mesmo próximo char; chars.next() avança e devolve outros chars.

Por exemplo na string abc:

  • peek() repetido → Some('a'), Some('a'), ...
  • next() repetido → Some('a'), Some('b'), Some('c'), None.

Com peek, tokenizar ++ e += vira ifs aninhados.

Implementação real do jsparagus:

rust
// https://github.com/mozilla-spidermonkey/jsparagus/blob/master/crates/parser/src/lexer.rs#L1769-L1791

'+' => match self.peek() {
    Some('+') => {
        self.chars.next();
        return self.set_result(
            TerminalId::Increment,
            SourceLocation::new(start, self.offset()),
            TokenValue::None,
        );
    }
    Some('=') => {
        self.chars.next();
        return self.set_result(
            TerminalId::AddAssign,
            SourceLocation::new(start, self.offset()),
            TokenValue::None,
        );
    }
    _ => return self.set_result(
        TerminalId::Plus,
        SourceLocation::new(start, self.offset()),
        TokenValue::None,
    ),
},

A mesma ideia vale para os demais operadores — e daí ampliamos o léxico de JavaScript.

JavaScript

Um léxico em Rust é meio entediante: parece C, com cadeias longas de if checando cada char.

O jogo fica interessante quando o alvo é JavaScript.

Abra a especificação da linguagem ECMAScript e relembre JS.

TIP

Lembro da primeira vez que abri a spec e fui chorar num canto — parecia texto estranho cheio de jargão. Se esbarrar nisso, veja o guia para ler a especificação.

Comentários

Comentários não têm significado semântico; dá para ignorá-los num runtime, mas precisam ser considerados em linter ou bundler.

Identificadores e Unicode

Quase sempre digitamos ASCII, mas o capítulo 11 — Source Text diz que o texto-fonte é Unicode. E o 12.6 — Names and Keywords diz que identificadores seguem a sintaxe “Default Identifier” do UAX #31. Em detalhe:

IdentifierStartChar ::
    UnicodeIDStart

IdentifierPartChar ::
    UnicodeIDContinue

UnicodeIDStart ::
    any Unicode code point with the Unicode property “ID_Start”

UnicodeIDContinue ::
    any Unicode code point with the Unicode property “ID_Continue”

Ou seja, var ಠ_ಠ vale, mas var 🦀 não — tem ID_Start e 🦀 não.

INFO

Publiquei o crate unicode-id-start para isso. Use unicode_id_start::is_id_start(char) e unicode_id_start::is_id_continue(char) para validar Unicode.

Keywords

Todas as palavras-chave (if, while, for…) precisam virar tokens inteiros. Coloque-as no enum Kind para não comparar strings no parser.

rust
pub enum Kind {
    Identifier,
    If,
    While,
    For
}

TIP

undefined não é keyword — não precisa entrar aqui.

Na prática, depois do identificador basta converter para keyword:

rust
fn match_keyword(&self, ident: &str) -> Kind {
    // all keywords are 1 < length <= 10
    if ident.len() == 1 || ident.len() > 10 {
        return Kind::Identifier;
    }
    match ident {
        "if" => Kind::If,
        "while" => Kind::While,
        "for" => Kind::For,
        _ => Kind::Identifier
    }
}

Token Value

Mais tarde compararemos identificadores, números e strings — por exemplo no linter.

Hoje esses valores ainda são fatias do texto; vamos pendurá-los como tipos Rust.

rust
pub enum Kind {
    Eof, // end of file
    Plus,
    Identifier,
    Number,
    String,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Token {
    /// Token Type
    pub kind: Kind,

    /// Start offset in source
    pub start: usize,

    /// End offset in source
    pub end: usize,

    pub value: TokenValue,
}

#[derive(Debug, Clone, PartialEq)]
pub enum TokenValue {
    None,
    Number(f64),
    String(String),
}

Ao tokenizar identificador foo ou string "bar":

Token { kind: Kind::Identifier, start: 0, end: 2, value: TokenValue::String("foo") }
Token { kind: Kind::String, start: 0, end: 4, value: TokenValue::String("bar") }

Converta para String Rust com let s = self.source[token.start..token.end].to_string() e grave em token.value = TokenValue::String(s).

Para número 1.23, há Token { start: 0, end: 3 }. Use parse: self.source[token.start..token.end].parse::<f64>() e guarde em token.value. Binário, octal e inteiros: exemplo no jsparagus.

Rust Optimizations

Smaller Tokens

É tentador colocar valores dentro de Kind e simplificar:

rust
pub enum Kind {
    Number(f64),
    String(String),
}

Mas o tamanho de um enum Rust é a união dos variantes. Esse enum pesa muito mais que um Kind de 1 byte. O parser usa Kind o tempo todo — manter 1 byte é obviamente mais rápido.

String Interning

String pura em compilador costuma ser cara:

  • aloca na heap
  • comparação é O(n)

String interning guarda uma cópia por valor distinto com id único. Uma alocação por literal/identificador distinto e comparação O(1).

Há várias libs em crates.io.

Um começo sólido é string-cache com tipo Atom e atom!("texto") em compile time.

Com isso, TokenValue fica:

rust
#[derive(Debug, Clone, PartialEq)]
pub enum TokenValue {
    None,
    Number(f64),
    String(Atom), 
}

e uma comparação vira matches!(value, TokenValue::String(atom!("string"))).