3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

字句解析器を書いたら正規表現の気持ちがわかった Part1

Last updated at Posted at 2025-12-09

Rustで作る自作コンパイラ入門シリーズ
Part1【字句解析】| Part2【構文解析】 | Part3【意味解析】 | Part4【コード生成】 | Part5【完結】


はじめに

プログラミング言語を作る...って言われると、なんかすごく難しそうですよね。

でも実際に作ってみると、意外とシンプルな処理の組み合わせだったりします。

今回のシリーズでは、RustでFizzBuzzがコンパイルできるミニ言語を作っていきます!


目次

  1. コンパイラの全体像
  2. 字句解析とは
  3. トークンを設計する
  4. Lexerを実装する
  5. 動かしてみる

コンパイラの全体像

コンパイラは大きく分けて4つのフェーズで構成されます:

ソースコード
    ↓
┌─────────────────┐
│  1. 字句解析     │  ← 今回ここ!
│    (Lexer)      │
└─────────────────┘
    ↓ トークン列
┌─────────────────┐
│  2. 構文解析     │  ← Part2
│    (Parser)     │
└─────────────────┘
    ↓ 抽象構文木(AST)
┌─────────────────┐
│  3. 意味解析     │  ← Part3
│    (Analyzer)   │
└─────────────────┘
    ↓ 検証済みAST
┌─────────────────┐
│  4. コード生成   │  ← Part4
│    (Generator)  │
└─────────────────┘
    ↓
機械語 / アセンブリ

字句解析とは

字句解析(Lexical Analysis) は、ソースコードの文字列を トークン(Token) という単位に分解する処理です。

let x = 42;

これを字句解析すると:

"let"  → Let(キーワード)
"x"    → Ident("x")(識別子)
"="    → Eq(等号)
"42"   → Number(42)(数値)
";"    → Semicolon(セミコロン)

人間が「空白で区切られた単語」を認識するのと同じような処理を、プログラムでやっているわけです。

正規表現の気持ちがわかる

字句解析を書いていると気づくのですが、やっていることは基本的に正規表現と同じなんです。

  • 数字が連続している → 数値リテラル
  • 英字+英数字が連続 → 識別子
  • 特定の文字列 → キーワード

正規表現が裏で何をやっているのか、実感できます。


トークンを設計する

まず、どんなトークンが必要か考えます。

今回作る言語の文法:

  • 変数宣言: let x = 10;
  • 関数定義: fn add(a, b) { return a + b; }
  • if文: if x > 0 { ... } else { ... }
  • while文: while x > 0 { ... }

トークンの種類

/// トークンの種類
#[derive(Debug, Clone, PartialEq)]
pub enum TokenKind {
    // リテラル
    Number(i64),     // 数値: 42, 123
    Ident(String),   // 識別子: foo, bar
    
    // キーワード
    Let,             // let
    Fn,              // fn
    If,              // if
    Else,            // else
    While,           // while
    Return,          // return
    True,            // true
    False,           // false
    
    // 演算子
    Plus,            // +
    Minus,           // -
    Star,            // *
    Slash,           // /
    Percent,         // %
    Eq,              // =
    EqEq,            // ==
    NotEq,           // !=
    Lt,              // <
    LtEq,            // <=
    Gt,              // >
    GtEq,            // >=
    And,             // &&
    Or,              // ||
    Not,             // !
    
    // 区切り文字
    LParen,          // (
    RParen,          // )
    LBrace,          // {
    RBrace,          // }
    Comma,           // ,
    Semicolon,       // ;
    Colon,           // :
    Arrow,           // ->
    
    // 特殊
    Eof,             // 入力の終わり
}

Rustのenumが最適!値を持てる(Number(i64))のが便利すぎる。

トークン構造体

位置情報も持たせると、エラーメッセージで行番号を表示できます:

/// トークン(種類 + 位置情報)
#[derive(Debug, Clone)]
pub struct Token {
    pub kind: TokenKind,
    pub line: usize,   // 行番号
    pub column: usize, // 列番号
}

Lexerを実装する

基本構造

/// 字句解析器
pub struct Lexer {
    input: Vec<char>,   // 入力文字列
    pos: usize,         // 現在位置
    line: usize,        // 現在行
    column: usize,      // 現在列
}

Vec<char>にしているのは、日本語コメントにも対応できるようにするため。

ヘルパーメソッド

impl Lexer {
    /// 現在の文字を取得
    fn current(&self) -> Option<char> {
        self.input.get(self.pos).copied()
    }
    
    /// 次の文字を先読み(peek)
    fn peek(&self) -> Option<char> {
        self.input.get(self.pos + 1).copied()
    }
    
    /// 1文字進める
    fn advance(&mut self) -> Option<char> {
        let ch = self.current()?;
        self.pos += 1;
        
        if ch == '\n' {
            self.line += 1;
            self.column = 1;
        } else {
            self.column += 1;
        }
        
        Some(ch)
    }
}

peek()で先読みできるのがポイント!===を区別するのに必要です。

数値を読む

fn read_number(&mut self) -> Token {
    let start_line = self.line;
    let start_column = self.column;
    
    let mut num_str = String::new();
    
    // 数字が続く限り読み取る
    while let Some(ch) = self.current() {
        if ch.is_ascii_digit() {
            num_str.push(ch);
            self.advance();
        } else {
            break;
        }
    }
    
    let value: i64 = num_str.parse().expect("Invalid number");
    Token::new(TokenKind::Number(value), start_line, start_column)
}

while let Some(ch)パターン、Rustっぽくて好き。

識別子 or キーワードを読む

fn read_ident_or_keyword(&mut self) -> Token {
    let start_line = self.line;
    let start_column = self.column;
    
    let mut ident = String::new();
    
    // 英数字とアンダースコアが続く限り読み取る
    while let Some(ch) = self.current() {
        if ch.is_alphanumeric() || ch == '_' {
            ident.push(ch);
            self.advance();
        } else {
            break;
        }
    }
    
    // キーワードか識別子か判定
    let kind = match ident.as_str() {
        "let"    => TokenKind::Let,
        "fn"     => TokenKind::Fn,
        "if"     => TokenKind::If,
        "else"   => TokenKind::Else,
        "while"  => TokenKind::While,
        "return" => TokenKind::Return,
        "true"   => TokenKind::True,
        "false"  => TokenKind::False,
        _        => TokenKind::Ident(ident),
    };
    
    Token::new(kind, start_line, start_column)
}

matchでキーワードをパターンマッチ!これがRustの醍醐味。

演算子・記号を読む

pub fn next_token(&mut self) -> Token {
    self.skip_whitespace();
    
    let start_line = self.line;
    let start_column = self.column;
    
    let Some(ch) = self.current() else {
        return Token::new(TokenKind::Eof, start_line, start_column);
    };
    
    // 数値
    if ch.is_ascii_digit() {
        return self.read_number();
    }
    
    // 識別子またはキーワード
    if ch.is_alphabetic() || ch == '_' {
        return self.read_ident_or_keyword();
    }
    
    // 記号類
    self.advance();  // 1文字消費
    
    let kind = match ch {
        '+' => TokenKind::Plus,
        '-' => {
            // -> か - かを判定
            if self.current() == Some('>') {
                self.advance();
                TokenKind::Arrow
            } else {
                TokenKind::Minus
            }
        }
        '*' => TokenKind::Star,
        '/' => TokenKind::Slash,
        '=' => {
            // == か = かを判定
            if self.current() == Some('=') {
                self.advance();
                TokenKind::EqEq
            } else {
                TokenKind::Eq
            }
        }
        '!' => {
            // != か ! かを判定
            if self.current() == Some('=') {
                self.advance();
                TokenKind::NotEq
            } else {
                TokenKind::Not
            }
        }
        // ... 他の記号も同様
        _ => panic!("Unknown character '{}'", ch),
    };
    
    Token::new(kind, start_line, start_column)
}

1文字の演算子と2文字の演算子を区別するのが少しトリッキー。
peek()で先読みして判断します。

コメントのスキップ

fn skip_whitespace(&mut self) {
    while let Some(ch) = self.current() {
        if ch.is_whitespace() {
            self.advance();
        } else if ch == '/' && self.peek() == Some('/') {
            // 行コメントをスキップ
            while let Some(c) = self.current() {
                if c == '\n' {
                    break;
                }
                self.advance();
            }
        } else {
            break;
        }
    }
}

// コメントは改行まで読み飛ばします。


動かしてみる

サンプルプログラム

fn main() {
    let source = r#"
// 足し算する関数
fn add(a, b) {
    return a + b;
}

fn main() {
    let x = 10;
    let y = 20;
    let result = add(x, y);
    
    if result > 25 {
        return 1;
    } else {
        return 0;
    }
}
"#;

    let mut lexer = Lexer::new(source);
    let tokens = lexer.tokenize();
    
    for (i, token) in tokens.iter().enumerate() {
        println!("{:3}: {:?}", i, token.kind);
    }
}

出力

=== Tokens ===
  0: Fn              at 3:1
  1: Ident("add")    at 3:4
  2: LParen          at 3:7
  3: Ident("a")      at 3:8
  4: Comma           at 3:9
  5: Ident("b")      at 3:11
  6: RParen          at 3:12
  7: LBrace          at 3:14
  8: Return          at 4:5
  9: Ident("a")      at 4:12
 10: Plus            at 4:14
 11: Ident("b")      at 4:16
 12: Semicolon       at 4:17
 13: RBrace          at 5:1
...(以下略)

Total: 56 tokens

ちゃんとトークンに分解されています!

let fizzbuzz = 1 + 2 * 3 - 4 / 2;

この1行が:

Let, Ident("fizzbuzz"), Eq, Number(1), Plus, Number(2), Star, Number(3), 
Minus, Number(4), Slash, Number(2), Semicolon, Eof

こう分解されます。人間が目で見て区切っているのと同じことを、プログラムでやっているんですね。


テストを書く

#[test]
fn test_simple_program() {
    let mut lexer = Lexer::new("let x = 42;");
    let tokens = lexer.tokenize();
    
    assert_eq!(tokens[0].kind, TokenKind::Let);
    assert_eq!(tokens[1].kind, TokenKind::Ident("x".to_string()));
    assert_eq!(tokens[2].kind, TokenKind::Eq);
    assert_eq!(tokens[3].kind, TokenKind::Number(42));
    assert_eq!(tokens[4].kind, TokenKind::Semicolon);
    assert_eq!(tokens[5].kind, TokenKind::Eof);
}

#[test]
fn test_operators() {
    let mut lexer = Lexer::new("== != <= >=");
    let tokens = lexer.tokenize();
    
    assert_eq!(tokens[0].kind, TokenKind::EqEq);
    assert_eq!(tokens[1].kind, TokenKind::NotEq);
    assert_eq!(tokens[2].kind, TokenKind::LtEq);
    assert_eq!(tokens[3].kind, TokenKind::GtEq);
}

テスト実行:

cargo test
running 7 tests
test lexer::tests::test_arrow ... ok
test lexer::tests::test_comment ... ok
test lexer::tests::test_function_def ... ok
test lexer::tests::test_keywords ... ok
test lexer::tests::test_numbers ... ok
test lexer::tests::test_operators ... ok
test lexer::tests::test_simple_program ... ok

test result: ok. 7 passed; 0 failed

全部通った!


まとめ

今回やったこと

項目 内容
トークン設計 enum TokenKindで30種類以上
Lexer実装 約300行
機能 数値、識別子、キーワード、演算子、コメント

学び

  1. 字句解析は正規表現と同じことをしている

    • パターンマッチで文字列を分類
  2. Rustのenumが最強

    • 値を持てる(Number(42)
    • パターンマッチと組み合わせて最高
  3. 先読み(peek)の重要性

    • ===を区別するには先読みが必要

次回予告

Part2では**構文解析(Parser)**を実装します!

トークン列を**木構造(AST)**に変換する処理です。

// これが...
let x = 1 + 2 * 3;

// こうなる!
Let {
    name: "x",
    value: Add(
        Number(1),
        Mul(Number(2), Number(3))
    )
}

演算子の優先順位(*+より先)をどう扱うか、お楽しみに!


質問・コメントあればぜひ!

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?