💨

ChatGPTを使ってRustで新しいプログラミング言語をつくり始めた話(四則演算)

に公開

はじめに

本記事では、自作プログラミング言語 Pyro に四則演算機能を追加した過程をご紹介いたします。
Pyro は Python ライクな構文を持ち、Rust にトランスパイルして実行可能な軽量言語です。

プロジェクト構成

プロジェクトは Cargo ワークスペースとして構成されています。

pyro_lang_test/
├── Cargo.toml
└── crates
    ├── pyroc        # フロントエンド(字句解析・構文解析)
    ├── pyrorts      # Rust コード生成
    └── pyroc-bin    # CLI エントリポイント
  • pyroc : Pyro ソースコードを AST に変換
  • pyrorts : AST を Rust ソースコード (out.rs) に変換
  • pyroc-bin : コマンドラインから .pyro ファイルを読み込み、out.rs を生成

背景

これまでの Pyro は print("hello") といった文字列出力しか扱えませんでした。
しかしながら、言語処理系として最低限の表現力を確保するためには数値演算が不可欠です。
そのため、まずは基本的な 四則演算(+、-、*、/) を実装することにいたしました。

実装内容

  1. 抽象構文木 (AST) の拡張
    crates/pyroc/src/lib.rs に二項演算子を表現する BinOp を追加し、Expr::Binary を導入しました。
    全体は以下になります。
./crates/pyroc/src/lib.rs
pub mod parser;

#[derive(Debug, Clone)]
pub enum BinOp {
    Add,
    Sub,
    Mul,
    Div,
}

#[derive(Debug, Clone)]
pub enum Expr {
    Number(f64),
    Str(String),
    Ident(String),
    Binary {
        op: BinOp,
        lhs: Box<Expr>,
        rhs: Box<Expr>,
    },
    Call {
        callee: String,
        args: Vec<Expr>,
    },
}

#[derive(Debug, Clone)]
pub enum Stmt {
    Expr(Expr),
}

#[derive(Debug, Clone)]
pub struct Module {
    pub stmts: Vec<Stmt>,
}



2. 演算子をトークンとして認識できるよう、crates/pyroc/src/parser.rsのnext_token を追加しました。
全体は以下になります。

./crates/pyroc/src/parser.rs
use crate::{BinOp, Expr, Module, Stmt};

#[derive(Debug, Clone, PartialEq)]
enum Tok {
    Ident(String),
    Str(String),
    Num(f64),
    LParen,
    RParen,
    Comma,
    Plus,
    Minus,
    Star,
    Slash,
    Newline,
    Eof,
}

struct Lexer<'a> {
    chars: std::str::Chars<'a>,
    peeked: Option<char>,
}

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

    fn peek(&mut self) -> Option<char> {
        if self.peeked.is_none() {
            self.peeked = self.chars.next();
        }
        self.peeked
    }
    fn bump(&mut self) -> Option<char> {
        if let Some(c) = self.peeked.take() {
            Some(c)
        } else {
            self.chars.next()
        }
    }

    fn next_token(&mut self) -> Result<Tok, String> {
        // 空白/タブ/CR をスキップ、# 以降は改行までコメントとして捨てる
        loop {
            match self.peek() {
                Some(' ' | '\t' | '\r') => {
                    self.bump();
                }
                Some('#') => {
                    // コメント本体を読み飛ばす(行末まで)
                    while let Some(c) = self.bump() {
                        if c == '\n' {
                            break;
                        }
                    }
                    // コメント行は「改行」として扱う
                    return Ok(Tok::Newline);
                }
                _ => break,
            }
        }

        match self.bump() {
            None => Ok(Tok::Eof),
            Some('\n') => Ok(Tok::Newline),
            Some('(') => Ok(Tok::LParen),
            Some(')') => Ok(Tok::RParen),
            Some(',') => Ok(Tok::Comma),
            Some('+') => Ok(Tok::Plus),
            Some('-') => Ok(Tok::Minus),
            Some('*') => Ok(Tok::Star),
            Some('/') => Ok(Tok::Slash),
            Some('"') => self.lex_string(),
            Some(c) if is_ident_start(c) => {
                let mut s = String::new();
                s.push(c);
                while let Some(nc) = self.peek() {
                    if is_ident_cont(nc) {
                        s.push(nc);
                        self.bump();
                    } else {
                        break;
                    }
                }
                Ok(Tok::Ident(s))
            }
            Some(c) if c.is_ascii_digit() => {
                let mut s = String::new();
                s.push(c);
                while let Some(nc) = self.peek() {
                    if nc.is_ascii_digit() || nc == '.' {
                        s.push(nc);
                        self.bump();
                    } else {
                        break;
                    }
                }
                let v: f64 = s
                    .parse()
                    .map_err(|_| format!("invalid number literal: {}", s))?;
                Ok(Tok::Num(v))
            }
            Some(c) => Err(format!("unexpected char: {}", c)),
        }
    }

    fn lex_string(&mut self) -> Result<Tok, String> {
        let mut s = String::new();
        while let Some(c) = self.bump() {
            match c {
                '\\' => match self.bump() {
                    Some('n') => s.push('\n'),
                    Some('t') => s.push('\t'),
                    Some('"') => s.push('"'),
                    Some('\\') => s.push('\\'),
                    Some(other) => s.push(other),
                    None => return Err("unterminated escape".into()),
                },
                '"' => return Ok(Tok::Str(s)),
                _ => s.push(c),
            }
        }
        Err("unterminated string".into())
    }
}

fn is_ident_start(c: char) -> bool {
    c.is_ascii_alphabetic() || c == '_'
}
fn is_ident_cont(c: char) -> bool {
    is_ident_start(c) || c.is_ascii_digit()
}

pub struct Parser<'a> {
    toks: Vec<Tok>,
    i: usize,
    _src: &'a str,
}

impl<'a> Parser<'a> {
    pub fn new(src: &'a str) -> Result<Self, String> {
        let mut lx = Lexer::new(src);
        let mut toks = Vec::new();
        loop {
            let t = lx.next_token()?;
            let is_eof = matches!(t, Tok::Eof);
            toks.push(t);
            if is_eof {
                break;
            }
        }
        Ok(Self {
            toks,
            i: 0,
            _src: src,
        })
    }

    fn at(&self) -> &Tok {
        self.toks.get(self.i).unwrap_or(&Tok::Eof)
    }
    fn bump(&mut self) {
        if self.i < self.toks.len() {
            self.i += 1;
        }
    }
    fn eat_newlines(&mut self) {
        while matches!(self.at(), Tok::Newline) {
            self.bump();
        }
    }

    pub fn parse_module(&mut self) -> Result<Module, String> {
        let mut stmts = Vec::new();
        self.eat_newlines();
        while !matches!(self.at(), Tok::Eof) {
            if matches!(self.at(), Tok::Newline) {
                self.bump();
                continue;
            }
            let e = self.parse_expr(0)?;
            stmts.push(Stmt::Expr(e));
            self.eat_newlines();
        }
        Ok(Module { stmts })
    }

    // 優先順位: * / (20) > + - (10)(左結合)
    fn precedence(op: &Tok) -> Option<u8> {
        match op {
            Tok::Plus | Tok::Minus => Some(10),
            Tok::Star | Tok::Slash => Some(20),
            _ => None,
        }
    }

    fn parse_expr(&mut self, min_bp: u8) -> Result<Expr, String> {
        let mut lhs = self.parse_primary()?;
        loop {
            let op_tok = self.at().clone();
            let prec = if let Some(p) = Self::precedence(&op_tok) {
                p
            } else {
                break;
            };
            if prec < min_bp {
                break;
            }
            self.bump(); // consume op
            let rhs = self.parse_expr(prec + 1)?; // left assoc
            lhs = match op_tok {
                Tok::Plus => Expr::Binary {
                    op: BinOp::Add,
                    lhs: Box::new(lhs),
                    rhs: Box::new(rhs),
                },
                Tok::Minus => Expr::Binary {
                    op: BinOp::Sub,
                    lhs: Box::new(lhs),
                    rhs: Box::new(rhs),
                },
                Tok::Star => Expr::Binary {
                    op: BinOp::Mul,
                    lhs: Box::new(lhs),
                    rhs: Box::new(rhs),
                },
                Tok::Slash => Expr::Binary {
                    op: BinOp::Div,
                    lhs: Box::new(lhs),
                    rhs: Box::new(rhs),
                },
                _ => unreachable!(),
            };
        }
        Ok(lhs)
    }

    fn parse_primary(&mut self) -> Result<Expr, String> {
        match self.at().clone() {
            Tok::Num(v) => {
                self.bump();
                Ok(Expr::Number(v))
            }
            Tok::Str(s) => {
                self.bump();
                Ok(Expr::Str(s))
            }
            Tok::Ident(name) => {
                self.bump();
                if matches!(self.at(), Tok::LParen) {
                    // 関数呼び出し(print 等)
                    self.bump();
                    let mut args = Vec::new();
                    if !matches!(self.at(), Tok::RParen) {
                        loop {
                            let arg = self.parse_expr(0)?;
                            args.push(arg);
                            match self.at() {
                                Tok::Comma => {
                                    self.bump();
                                }
                                Tok::RParen => break,
                                t => return Err(format!("expected ',' or ')', got {:?}", t)),
                            }
                        }
                    }
                    if !matches!(self.at(), Tok::RParen) {
                        return Err("expected ')'".into());
                    }
                    self.bump();
                    Ok(Expr::Call { callee: name, args })
                } else {
                    Ok(Expr::Ident(name))
                }
            }
            Tok::LParen => {
                self.bump();
                let e = self.parse_expr(0)?;
                if !matches!(self.at(), Tok::RParen) {
                    return Err("expected ')'".into());
                }
                self.bump();
                Ok(e)
            }
            other => Err(format!("unexpected token: {:?}", other)),
        }
    }
}

  1. 構文解析 (Parser)
    2で演算子の優先順位を考慮した再帰下降パーサを実装しました。
  • / / を加減算より強く結合させるため、次のように定義しています。

全体は以下になります。

./crates/pyroc/src/parser.rs

    // 優先順位: * / (20) > + - (10)(左結合)
    fn precedence(op: &Tok) -> Option<u8> {
        match op {
            Tok::Plus | Tok::Minus => Some(10),
            Tok::Star | Tok::Slash => Some(20),
            _ => None,
        }
    }



二項演算の構築部は次の通りです。

./crates/pyroc/src/parser.rs
lhs = match op_tok {
    Tok::Plus  => Expr::Binary { op: BinOp::Add, lhs: Box::new(lhs), rhs: Box::new(rhs) },
    Tok::Minus => Expr::Binary { op: BinOp::Sub, lhs: Box::new(lhs), rhs: Box::new(rhs) },
    Tok::Star  => Expr::Binary { op: BinOp::Mul, lhs: Box::new(lhs), rhs: Box::new(rhs) },
    Tok::Slash => Expr::Binary { op: BinOp::Div, lhs: Box::new(lhs), rhs: Box::new(rhs) },
    _ => unreachable!(),
};
  1. Rust コード生成
    pyrorts 側で Expr::Binary を Rust の式に変換する処理を追加しました。
./crates/pyrorts/src/lib.rs
use pyroc::{BinOp, Expr, Module, Stmt};

pub fn generate(m: &Module) -> String {
    let mut body = String::new();
    for stmt in &m.stmts {
        match stmt {
            Stmt::Expr(Expr::Call { callee, args }) if callee == "print" => {
                if let Some(Expr::Str(s)) = args.get(0) {
                    // フォーマット文字列は通常の文字列で、内容はエスケープして挿入
                    body.push_str(&format!("println!(\"{}\");\n", escape_rust(s)));
                } else {
                    body.push_str("println!(\"<print: non-string not supported yet>\");\n");
                }
            }
            _ => body.push_str("// (unsupported stmt)\n"),
        }
        match stmt {
            Stmt::Expr(Expr::Call { callee, args }) if callee == "print" => {
                if let Some(arg) = args.get(0) {
                    let expr_rs = expr_to_rust(arg);
                    body.push_str(&format!("println!(\"{{}}\", {});\n", expr_rs));
                } else {
                    body.push_str("println!(\"<print: missing arg>\");\n");
                }
            }
            Stmt::Expr(_) => { /* bare expr: no-op */ }
        }
    }
    format!("fn main(){{\n{}\n}}\n", indent(&body, 4))
}

fn expr_to_rust(e: &Expr) -> String {
    match e {
        Expr::Number(n) => n.to_string(),
        Expr::Str(s) => format!("\"{}\"", escape_rust(s)),
        Expr::Ident(name) => name.clone(),
        Expr::Binary { op, lhs, rhs } => {
            let l = expr_to_rust(lhs);
            let r = expr_to_rust(rhs);
            let op_str = match *op {
                BinOp::Add => "+",
                BinOp::Sub => "-",
                BinOp::Mul => "*",
                BinOp::Div => "/",
            };
            format!("({} {} {})", l, op_str, r)
        }
        Expr::Call { .. } => "\"<unsupported call>\"".to_string(),
    }
}

fn indent(s: &str, n: usize) -> String {
    let pad = " ".repeat(n);
    s.lines()
        .map(|ln| format!("{}{}", pad, ln))
        .collect::<Vec<_>>()
        .join("\n")
}

fn escape_rust(s: &str) -> String {
    let mut out = String::new();
    for ch in s.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\t' => out.push_str("\\t"),
            _ => out.push(ch),
        }
    }
    out
}

実行例

以下がpyroのコードになります。
全体は以下になります。

./examples/arith.pyro
# 足し算・掛け算
print(1 + 2 * 3)

# 括弧の優先順位
print((1 + 2) * 3)

# 引き算と割り算
print(10 / 2 - 1)

# 複雑な式
print(2 + 3 * (4 - 1))

トランスパイルでout.rsを作成

以下のコマンドを実行し、out.rsを生成します。

cargo run -p pyroc-bin



生成されるout.rsは以下になります。

./out.rs
fn main(){
    println!("{}", (1 + (2 * 3)));
    println!("{}", ((1 + 2) * 3));
    println!("{}", ((10 / 2) - 1));
    println!("{}", (2 + (3 * (4 - 1))));
}

ビルドとコンパイル

以下コマンドでビルドとコンパイルを行います。

$ cargo clean
$ cargo buid
$ cargo run -p pyroc-bin
$ cargo new host
$ cp out.rs host/src/main.rs
$ cd host && cargo run

実行結果

実行すると以下が出力されます。

7
9
4
11

まとめと今後の展望

今回の実装により、Pyroは整数リテラルと四則演算を扱えるようになりました。
次のステップとして、以下の機能拡張を計画しています。
変数代入とスコープ管理

型の拡張(浮動小数点、文字列演算)

制御構文(if、while など)

言語処理系の実装は試行錯誤の積み重ねですが、機能が一つずつ動作する瞬間は大きな達成感があります。
本記事が自作言語開発に取り組む方々の参考となれば幸いです。

コラボスタイル Developers

Discussion