Membuat Bahasa Pemrograman Sederhana dengan Rust dan LLVM - Bagian 1 Lexer

Tahap pertama sebuah compiler adalah mengubah kode sumber (source code) menjadi sebuah token, token ini adalah sebuah kata yang dapat dimengerti oleh bahasa pemrograman tersebut, tahap ini disebut Lexical Analysis atau juga Scanner.

Untuk kode yang sudah lengkap bisa dilihat di github: https://github.com/aldidana/hitung

Pada gambar diatas setiap character dalam kode sumber akan ditentukan masuk ke bagian token yang sesuai.

Membuat Program Baru

Untuk mengikuti ini pastikan Rust sudah ter-install, kemudian ikuti perintah dibawah ini untuk membuat program Rust baru:

cargo new hitung

Struktur File

Struktur yang akan dibuat untuk saat ini:

hitung
│   Cargo.toml   
│
└───src
	| main.rs
	| lexer.rs
   	| token.rs

Token

Kita akan memulai pada file token.rs, tambahkan kode berikut:

#[derive(Debug, PartialEq, Clone)]
pub enum Token {
	LParen,
	RParen,
	Add,
	Sub,
	Mul,
	Div,
	Num(f64),
	EOF,
	ILLEGAL,
	ASSIGN,
	IDENTIFIER(String),
	If,
	Then,
	Else,
	EQ,
	LT,
	GT,
}

impl From<i32> for Token {
	fn from(n: i32) -> Self {
		Token::Num(n as f64)
	}
}

impl Token {
	//Left Binding Power
	pub fn lbp(&self) -> usize {
		match *self {
			Token::Add => 10,
			Token::Sub => 10,
			Token::Mul => 20,
			Token::Div => 20,
			Token::LParen => 99,
			Token::ASSIGN => 100,
			Token::RParen => 0,
			_ => 0,
		}
	}
}
token.rs

Dibagian ini kita membuat sebuah enum Token dengan nama yang sudah ditentukan, enum juga dapat diberikan sebuah tipe data didalamnya seperti Token::Num(f64) diatas.

Selanjutnya Token juga dapat melakukan konversi dari tipe data i32 menjadi f64, dengan implementasi trait From, trait From ini memiliki metode atau fungsi from yang mengambil tipe data generic, contoh:

trait From<T> {
  pub fn from(_: T) -> Self
}

// Token mempunyai metode atau fungsi from

Token::from(2) // akan menjadi tipe data f64
Token::Num(2.0)

Pada kode terakhir token disini untuk menentukan seberapa beban atau prioritas sebuah token dibanding token lainnya misalnya perkalian lebih dulu di evaluasi dibandingkan dengan penjumlahan, sehingga 3+2*2 adalah 7 bukan 10, tetapi jika hasilnya ingin 10 bisa menggunakan tanda kurung (3+2)*2.

Test Token

Jangan lupa menambahkan test untuk token.rs, tambahkan kode berikut ini setelah baris terakhir dari kode yang ada di dalam token.rs:

#[cfg(test)]
mod test {
	use super::*;

	#[test]
	fn test_token_add() {
		let actual = Token::Add.lbp();
		let expected: usize = 10;

		assert_eq!(expected, actual);
	}

	#[test]
	fn test_token_sub() {
		let actual = Token::Sub.lbp();
		let expected: usize = 10;

		assert_eq!(expected, actual);
	}

	#[test]
	fn test_token_mul() {
		let actual = Token::Mul.lbp();
		let expected: usize = 20;

		assert_eq!(expected, actual);
	}

	#[test]
	fn test_token_div() {
		let actual = Token::Div.lbp();
		let expected: usize = 20;

		assert_eq!(expected, actual);
	}

	#[test]
	fn test_token_open_paren() {
		let actual = Token::LParen.lbp();
		let expected: usize = 99;

		assert_eq!(expected, actual);
	}

	#[test]
	fn test_token_close_paren() {
		let actual = Token::RParen.lbp();
		let expected: usize = 0;

		assert_eq!(expected, actual);
	}

	#[test]
	fn test_token_assignment() {
		let actual = Token::ASSIGN.lbp();
		let expected: usize = 100;

		assert_eq!(expected, actual);
	}
}
token.rs

Disini kita membuat test untuk mengecek left binding power dari sebuah token.

Lexer

Lanjut ke file lexer.rs, pada bagian awal baris tambahkan kode berikut ini:

use std::iter::Peekable;
use std:str:Chars;

use crate::token::Token;
lexer.rs

Jangan khawatir akan dijelaskan mengenai barisan kode tersebut. Disini kita akan menggunakan Peekable dan Chars. Peekable yaitu sebuah iterator yang mempunyai metode atau fungsi peek() yang akan mengembalikan sebuah reference dari elemen berikutnya, sehingga posisi sebuah char tidak akan maju ke elemen berikutnya, contohnya seperti ini:

let array = [1, 2, 3]; //sebuah array

let mut iter = array.iter().peekable(); // maka iter akan mempunyai metode atau fungsi peek

iter.peek() // akan mengembalikan tipe optional sehinnga isinya Some(1)
iter.peek() // Some(1)
iter.peek() // Some(1)

// Tidak peduli seberapa banyak kita memanggil fungsi peek() iterator tidak akan maju ke elemen berikutnya

iter.next() // maju ke elemen berikutnya

iter.peek() // Some(2)

Kemudian menggunakan Chars karena akan menyimpan input dari kode sumber menjadi sebuah Peekable yang didalamnya mempunyai tipe Chars. Selanjutnya untuk token adalah meng-import agar dapat mengakses Enum Token yang sudah dibuat sebelumnya.

Tambahkan kode berikut ini setelah kode lexer.rs terakhir:

#[derive(Debug)]
pub struct Lexer<'a> {
	input: Peekable<Chars<'a>>,
}

impl <'a> Lexer<'a> {
	pub fn new(input: &str) -> Self {
    	Lexer {
        	input: input.chars().peekable(),
        }
        
	pub fn lex(mut self) -> Vec<Token> {
		let mut tokens = vec![];
        loop {
            let current_token = self.next_token();
            if current_token == Token::EOF || current_token == Token::ILLEGAL {
                tokens.push(current_token);
                break;
            } else {
                tokens.push(current_token);
            }
        }

        tokens
    }
}
lexer.rs

Pada bagian ini kita membuat Struct Lexer dengan reference yang ditandai oleh lifetime 'a, Struct Lexer mempunyai field input berupa Peekable yang mempunyai tipe data Chars, Chars juga membutuhkan lifetime parameter.

Berikut in mengenai penjelasan sederhana mengenai lifetime diatas:

struct DB {
	.....
}

struct App {
	database: &DB, // borrow atau meminjam struct DB diatas
}

// kode diatas tidak akan bisa dicompile
// dikarenakan struct DB tidak diketahui lifetimenya

struct App<'a> {
	database: &'a DB,
}

// Kode diatas berhasil dicompile
// karena field database yang mempunyai tipe DB mempunyai lifetime yang sama seperti App
// Struct App tidak boleh outlive dari reference yaitu struct DB

fungsi new() dari Lexer adalah associated functions untuk membuat sebuah instance dari tipe data itu sendiri.

.... {
	fn new() -> Self // ini adalah associated function karena tidak mengambil self pada parameter pertama

	fn move(&mut self) // methods
    fn attack(self) // methods
}

Sedangkan fungsi lex() adalah sebuah methods dari Lexer, fungsi ini akan melakukan perulangan hingga input berupa kode sumber selesai atau berakhir.

Masih di dalam file lexer.rs, tambahkan kode dibawah ini tepat setelah fungsi lex dan masih di dalam block impl dari Lexer

// masih di dalam impl Lexer
.... {
	pub fn next_token(&mut self) -> Token {
		match self.input.peek() {
			Some(ch) => match ch {
				' ' | '\t' => {
					self.input.next();
					self.next_token()
				}
				'\n' => {
					self.input.next();
					self.next_token()
				}
				ch if ch.is_numeric() => self.read_numeric(),
				'+' => {
					self.input.next();
					Token::Add
				}
				'-' => {
					self.input.next();
					Token::Sub
				}
				'*' => {
					self.input.next();
					Token::Mul
				}
				'/' => {
					self.input.next();
					Token::Div
				}
				'(' => {
					self.input.next();
					Token::LParen
				}
				')' => {
					self.input.next();
					Token::RParen
				}
				ch if ch.is_alphabetic() => {
					self.read_identifier()
				}
				'=' => {
					self.input.next();
					match self.input.peek() {
						Some(c) => {
							if *c == '=' {
								self.input.next();
								Token::EQ
							} else {
								self.input.next();
								Token::ASSIGN
							}
						}
						_ => {
							self.next_token()
						}
					}
				}
				'<' => {
					self.input.next();
					Token::LT
				}
				'>' => {
					self.input.next();
					Token::GT
				}
				_ => {
					self.input.next();
					Token::ILLEGAL
				}
			},
			None => Token::EOF,
		}
	}

	fn read_numeric(&mut self) -> Token {
		let mut literal = String::new();

		loop {
			match self.input.peek() {
				Some(&ch) => {
					if ch.is_numeric() || ch == '.' {
						literal.push(ch);
						self.input.next();
					} else {
						break;
					}
				}
				_ => break,
			}
		}

		match literal.parse() {
			Ok(l) => Token::Num(l),
			Err(_e) => Token::ILLEGAL,
		}
	}

	fn read_identifier(&mut self) -> Token {
		let mut literal = String::new();

		loop {
			match self.input.peek() {
				Some(&ch) => {
					if !ch.is_alphabetic() {
						break;
					}
					if ch.is_ascii_whitespace() {
						// self.input.next();
						break;
					};
					literal.push(ch);
					self.input.next();
				},
				_ => break
			}
		};

		match literal.as_str() {
			"if" => Token::If,
			"then" => Token::Then,
			"else" => Token::Else,
			_ => Token::IDENTIFIER(literal),
		}
	}
}
lexer.rs

fungsi berikutnya ini cukup sederhana, megecek input dari kode sumber  yang akan menjadi token yang sudah kita tentukan.

&mut self pada fungsi adalah agar kita dapat mengubah data dari object itu sendiri, tanpa mengambil atau memindahkan ownership.

&mut self: mutable reference tanpa mengambil/memindahkan ownership
  • &mut self: mutable reference tanpa mengambil/memindahkan ownership
  • mut self: mutable dengan mengambil/memindahkan ownership
  • self: immutable dengan mengambil/memindahkan ownership
  • &self: immutable tanpa mengambil/memindahkan ownership

Test Lexer

Terakhir kita tambahkan test untuk lexer.rs pada baris terakhir

impl<'a> Lexer<'a> {
	......
}

#[cfg(test)]
mod test {
	use super::*;

	#[test]
	fn test_num() {
		let lexer = Lexer::new(r#"32"#);
		let tokens = lexer.lex();

		let expected = vec![Token::from(32), Token::EOF];
		assert_eq!(expected, tokens);
	}

	#[test]
	fn test_num_float() {
		let lexer = Lexer::new(r#"32.5"#);
		let tokens = lexer.lex();

		let expected = vec![Token::Num(32.5), Token::EOF];
		assert_eq!(expected, tokens);
	}

	#[test]
	fn test_num_whitespace() {
		let lexer = Lexer::new(r#"32 2"#);
		let tokens = lexer.lex();

		let expected = vec![Token::from(32), Token::from(2), Token::EOF];

		assert_eq!(expected, tokens);
	}

	#[test]
	fn test_num_operator() {
		let lexer = Lexer::new(r#"-+/*"#);
		let tokens = lexer.lex();

		let expected = vec![
			Token::Sub,
			Token::Add,
			Token::Div,
			Token::Mul,
			Token::EOF,
		];

		assert_eq!(expected, tokens);
	}

	#[test]
	fn test_assignment() {
		let lexer = Lexer::new(r#"a = 123"#);
		let tokens = lexer.lex();

		let expected = vec![
			Token::IDENTIFIER("a".to_string()),
			Token::ASSIGN,
			Token::from(123),
			Token::EOF,
		];

		assert_eq!(expected, tokens);
	}

	#[test]
	fn test_conditional() {
		let lexer = Lexer::new(r#"if 1 < 2 then 1 else 0"#);
		let tokens = lexer.lex();

		let expected = vec![
			Token::If,
			Token::from(1),
			Token::LT,
			Token::from(2),
			Token::Then,
			Token::from(1),
			Token::Else,
			Token::from(0),
			Token::EOF,
		];

		assert_eq!(expected, tokens);
	}
}
lexer.rs

Main

Kembali ke fungsi utama untuk menjalankan program ini yaitu main.rs, tambahkan kode berikut:

use std::io;
use std::io::Write;

mod lexer;
mod token;

use lexer::Lexer;

fn main() {
    loop {
        println!();
        print!("> ");

        io::stdout()
            .flush()
            .expect("Could not read from standard input.");

        let mut input = String::new();

        io::stdin()
            .read_line(&mut input)
            .expect("Could not read from standard input.");

        let lexer = Lexer::new(input.as_str());
        let result = lexer.lex();

        println!("{:?}", result);
    }
}
main.rs

Pada bagian ini kita menggunakan standard library untuk io (input/output) dan juga Trait Write agar io::stdout() dapat menjalankan fungsi flush(). Kemudian kita membaca setiap input yang masuk ke dalam program dan memanggil fungsi yang ada pada Lexer untuk menghasilkan sebuah token.

Jalankan program dengan perintah berikut pada terminal:

cargo run

Kemudian ketik input yang diinginkan lalu tekan enter pada keyboard, misalnya:

if 1 > 2 then 1 else 0

maka akan menghasilkan token sebagai berikut:

[If, Num(1.0), GT, Num(2.0), Then, Num(1.0), Else, Num(0.0), EOF]

contoh lain:

a = 123

maka hasilnya:

[IDENTIFIER("a"), ASSIGN, Num(123), EOF]

Sekian untuk bagian pertama dari Membuat Bahasa Pemrograman Sederhana dengan Rust dan LLVM.

  1. (Disini) Bagian 1 - https://aldidana.com/membuat-bahasa-pemrograman-rust-llvm-lexer/
  2. Bagian 2 - https://aldidana.com/membuat-bahasa-pemrograman-rust-llvm-parser/
  3. Bagian 3 - https://aldidana.com/membuat-bahasa-pemrograman-rust-llvm-jit-compiler/
Aldi Perdana

Aldi Perdana