Part 10: Variable Declarations

This post is part of a series about learning Rust and building a small programming language.


Now that the parser can handle multiple statements and the usage of variables, let’s add the ability to actually declare variables.

First off, the lexer now lexes a couple new things: the let keyword and the equals sign.

When the parser tries to parse a statement and sees a let token, it knows it’s looking at a variable declaration. After the let token it expects to find a identifier (the variable name), an equals sign, and then an expression for the initial value of the variable. The variable name and the initial value expression then make up a Declare AST node.

fn parse_statement<'a, I: Iterator<Item = &'a Token>>(it: &mut Peekable<'a, I>) -> Option<Statement> {
	// ...
	let node = match token {
		Token::Let => {
			let name: String;
			if let Some(Token::Identifier(s)) = it.peek() {
				name = s.clone();
				it.next();
			} else {
				panic!("expected identifier after let");
			}
			expect_token!(it, Equals, "expected equals after identifier after let");
			let value = parse_expression(it).expect("initial value in let statement");
			Some(Statement::Declare { name, value })
		}
		// ...
	};
	// ...
}

expect_token! is a simple macro I wrote to handle expecting to see a specific token in the stream and panicking if it’s not there, since that’s a pattern that was coming up frequently:

macro_rules! expect_token {
	($stream:ident, $token:ident, $msg:expr) => {
		if let Some(Token::$token) = $stream.peek() {
			$stream.next();
		} else {
			panic!($msg);
		}
	};
}

Next, to actually evaluate variable declarations, the evaulator needs to have some concept of a context. Right now, every expression can be evaluated without any external state. But, when a variable is declared, we want it to be accessible later on in the code, so there needs to be somewhere to store that information.

For now, the only information we need is the map of variable names to their values.

struct Context {
	variables: HashMap<String, Value>,
}

There are also a few methods for Context, one to construct a new context and one to declare a variable with an initial value.

impl Context {
	fn new() -> Self { 
		Self {
			variables: HashMap::new(),
		}
	}

	fn declare_variable(&mut self, name: &str, value: Value) {
		if self.variables.contains_key(name) {
			panic!("cannot re-declare variable {}", name);
		} else {
			self.variables.insert(name.into(), value);
		}
	}
}

Every eval_ function has also changed to take a reference to the current context[1] and the main eval function creates a context before evaluating each statement.

With that, declaration statements can be evaluated just by calling the declare_variable method on the context:

fn eval_declare_variable(name: &str, value: &Node, context: &mut Context) {
	let val = eval_expr(value, context);
	context.declare_variable(name, val);
}

And we can actually set and read variables now[2]:

fn main() {
	let code = "let foo = 1; dbg(foo)";
	let tokens = tokenize(&code);
	let statements = parse(&tokens);
	eval(&statements);
}
$ cargo run
Integer(1)

1.

For now a simple mutable reference is fine, because there’s only ever one context: the global one. But, in the future, this will need to be something a bit more complicated.

2.

The dbg function is a builtin I added that prints out the Rust version of the Value it’s passed.

Comments

Comments powered by ActivityPub. To respond to this post, enter your username and instance below, or copy its URL into the search interface for client for Mastodon, Pleroma, or other compatible software. Learn more.

Reply from your instance: