字句解析器(Lexer)の実装——ソースコードをトークン列に変換する

字句解析とは何か

ソースコードは、コンパイラから見るとただの文字列です。これを1文字ずつ扱っていると、次のステップである構文解析(文法に沿って意味のある構造を組み立てる処理)が煩雑になります。そこでまず、文字列を「意味のある最小単位」であるトークンの列に変換します。これが字句解析(Lexing / Tokenizing)です。

例えばlet x = 1 + 2;という文字列は、次のようなトークン列になります。

Let
Ident("x")
Eq
Int(1)
Plus
Int(2)
Semicolon
Eof

空白やコメントはこの段階で読み飛ばして捨てます。字句解析器の責務は「文字列 → トークン列」の変換だけであり、意味の解釈(型が合っているか、変数が定義済みかなど)は一切行いません。

トークンの設計

トークンの種類は次のように定義しました。まだ構文解析・型検査・バイトコードの設計を詰めていないため、リテラル・基本的なキーワード(let/fn/if/else/while/for/in/return/break/continue/true/false)・演算子・区切り記号のみを対象にしています。構造体やジェネリクス、例外処理向けのキーワードは、必要になった時点で追加していきます。

#[derive(Debug, Clone, PartialEq)]
pub enum TokenKind {
    // Literals
    Int(i64),
    Float(f64),
    Str(String),
    Ident(String),

    // Keywords
    Let,
    Fn,
    If,
    Else,
    While,
    For,
    In,
    Return,
    Break,
    Continue,
    True,
    False,

    // Operators and symbols
    Plus,
    Minus,
    Star,
    Slash,
    Percent,
    Eq,
    EqEq,
    NotEq,
    Lt,
    LtEq,
    Gt,
    GtEq,
    AndAnd,
    OrOr,
    Bang,
    Dot,
    Comma,
    Semicolon,
    Colon,
    LParen,
    RParen,
    LBrace,
    RBrace,
    LBracket,
    RBracket,

    Eof,
}

各トークンには、種類(TokenKind)に加えて、ソースコード上の行・列番号を持たせています。

#[derive(Debug, Clone, PartialEq)]
pub struct Token {
    pub kind: TokenKind,
    pub line: usize,
    pub column: usize,
}

エラーメッセージで「3行目5列目が不正です」と報告するために必要な情報で、字句解析としては標準的な機能です。

Lexerの実装

Lexerは、ソース文字列をPeekable<Chars>として保持します。Peekableは、次の要素を消費せずに覗き見できる(peek())イテレータアダプタです。

pub struct Lexer<'a> {
    chars: Peekable<Chars<'a>>,
    line: usize,
    column: usize,
}

字句解析では「次の文字を見てから、読むかどうかを決める」という処理が頻繁に発生します。例えば=を読んだ時点では、それが単独の代入演算子=なのか、比較演算子==の一部なのかは次の文字を見るまで分かりません。peek()で覗いてから判断し、確定してからadvance()(実際に1文字消費する)という2段構えにすることで、この「先読みして判断する」処理を実現しています。

トークン化のエントリポイントはtokenize()で、next_token()Eofが出るまで呼び続けてVec<Token>にまとめます。

pub fn tokenize(mut self) -> Result<Vec<Token>, LexError> {
    let mut tokens = Vec::new();
    loop {
        let token = self.next_token()?;
        let is_eof = token.kind == TokenKind::Eof;
        tokens.push(token);
        if is_eof {
            break;
        }
    }
    Ok(tokens)
}

next_token()は、空白・コメントを読み飛ばした後、次の1文字を見て処理を振り分けます。

fn next_token(&mut self) -> Result<Token, LexError> {
    self.skip_whitespace_and_comments();

    let (line, column) = (self.line, self.column);

    let Some(&c) = self.chars.peek() else {
        return Ok(Token { kind: TokenKind::Eof, line, column });
    };

    let kind = if c.is_ascii_digit() {
        self.read_number()?
    } else if c == '"' {
        self.read_string()?
    } else if c.is_alphabetic() || c == '_' {
        self.read_ident_or_keyword()
    } else {
        self.read_symbol()?
    };

    Ok(Token { kind, line, column })
}

最初の1文字を見れば、どの種類のトークンを読もうとしているかがほぼ確定するという、字句解析における典型的な構造です。数字ならread_number"ならread_string、英字/_ならread_ident_or_keyword、それ以外は記号としてread_symbolに処理を委ねます。

数値リテラルと「メソッド呼び出しの.」の区別

数値の読み取りで少し工夫が必要なのが、小数点の扱いです。3.14はfloatですが、42.fooのような(将来メソッド呼び出し構文を導入した場合の).はfloatの一部ではありません。そこで、.の次の文字が数字かどうかを先読みしてから、floatとして読み進めるかを判断しています。

let mut is_float = false;
if self.chars.peek() == Some(&'.') {
    let mut lookahead = self.chars.clone();
    lookahead.next();
    if lookahead.peek().is_some_and(|c| c.is_ascii_digit()) {
        is_float = true;
        text.push('.');
        self.advance();
        // ...続けて小数部を読み取る
    }
}

Peekable(正確には内部のCharsイテレータ)はCloneを実装しているので、.clone()してもう1文字分だけ先読みし、それを本読み込みには反映しない、という使い方ができます。

記号と2文字演算子

==!=<=>=&&||のような2文字の演算子は、1文字目を読んだ後に「次の文字が期待した文字かどうか」をconsume_ifで確認し、一致すれば消費、しなければ単独の演算子として扱います。

'=' => {
    if self.consume_if('=') {
        TokenKind::EqEq
    } else {
        TokenKind::Eq
    }
}

&|は、この言語では&&||としてしか使わない設計にしているため、単独の&|が来た場合はエラーにしています。

文字列リテラルとエスケープシーケンス

文字列は"から"までを読み取り、\n\t\"\\のエスケープシーケンスを解釈します。閉じる"が見つからないまま入力が終わった場合や、未知のエスケープシーケンスが来た場合はLexErrorを返します。

テスト

各読み取りロジックに対応するユニットテストを書いています。トークン列全体を期待値と比較する形にすると、「小数点とメソッド呼び出しの.が区別できているか」「行コメントが正しく読み飛ばされるか」「行・列番号が正しく更新されるか」といった振る舞いを1つずつ検証できます。

#[test]
fn method_call_dot_is_not_confused_with_float() {
    assert_eq!(
        kinds("42.foo"),
        vec![
            TokenKind::Int(42),
            TokenKind::Dot,
            TokenKind::Ident("foo".to_string()),
            TokenKind::Eof
        ]
    );
}

動作確認

コマンドライン引数で渡されたファイルを読み込み、Lexerにかけてトークン列をそのまま表示するCLIを用意しました。構文解析がまだ無いので、確認できるのはここまでです。

$ cat examples/hello.na
let x = 1 + 2 * 3;
if (x > 5) {
    x;
} else {
    0;
}

$ cargo run -- examples/hello.na
Token { kind: Let, line: 1, column: 1 }
Token { kind: Ident("x"), line: 1, column: 5 }
Token { kind: Eq, line: 1, column: 7 }
Token { kind: Int(1), line: 1, column: 9 }
Token { kind: Plus, line: 1, column: 11 }
Token { kind: Int(2), line: 1, column: 13 }
Token { kind: Star, line: 1, column: 15 }
Token { kind: Int(3), line: 1, column: 17 }
Token { kind: Semicolon, line: 1, column: 18 }
Token { kind: If, line: 2, column: 1 }
Token { kind: LParen, line: 2, column: 4 }
...
Token { kind: Eof, line: 6, column: 2 }

想定通りのトークン列が得られています。

備考

文法スタイルをC++寄りに決めた

Lexer自体の実装には影響しませんが、この言語の文法スタイルの方向性を検討しました。

  • 文末の;は必須にする(Rustのように省略できる式指向のスタイルにはしない)
  • 関数の戻り値は必ずreturn文で明示する(Rustのように「ブロック末尾の式がそのまま戻り値になる」仕組みは採用しない)
  • ifwhileの条件式は(...)で囲む(C/C++/Java/JS系の慣習に合わせる)

Rustで実装しているとはいえ、作る言語自体の意味論・構文をRustに寄せる必然性はありません。ヒープオブジェクトをガベージコレクションで管理するGCベースの実行環境を想定していることもあり、雰囲気としてはRustよりもC++やJavaに近いスタイルを選びました。この方針は構文解析の実装で文法規則として具体化していきます。

トークン列をVecにまとめて返す設計にした理由

Lexer::tokenize()は、トークンを1つずつ返すイテレータにするのではなく、最初からVec<Token>にまとめて返す設計にしました。大規模なソースファイルを扱う本格的なコンパイラでは、メモリ効率や後続処理とのパイプライン化のために遅延評価(1トークンずつ必要な時に生成する)が採用されることが多いですが、扱うソースファイルの規模を考えるとその最適化の必要性は薄く、構文解析側の実装が素直になる「まとめて返す」方式を選びました。パフォーマンス上の理由が具体的に出てきた場合は、後からIteratorベースの設計に変更することもできます。