BASIC Interpreter Part Two: Variables
It's been a bit longer than I would have liked between the first post and this one, but life has a habit of getting in the way.
In my last post, I created a skeleton interpreter that could read very basic programs: we could PRINT
, and use GO TO
to jump to a different line in the program.
Variables
That's not much of a program at all - programs have data, so the next logical step is adding support for variables.
Variables in BASIC have essentially three types:
- Number
- String
- Array
Numbers
and strings are defined with the LET
keyword, while arrays are denoted by DIM
. There are also restrictions on variable naming. A number variable name can have any length, and can contain (but not begin with) a number. In this example, lines 10, 20, and 30 are all valid, however line 40 is not:
10 LET a=10
20 LET apples=5
30 LET b1234=10
40 LET 123=50
Strings are limited to single alphabetical character variable names, terminated by $
:
10 a$=apples
For the sake of simplicity and brevity, we're not going to implement Arrays
in this section - they'll come later. For now, I just want to focus on allowing us to read a variable, but not perform any operations on it.
To start with, we need to define an Enum
to hold our variable data:
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum Primitive {
Int(i64),
String(String),
}
I'm defining numbers as signed 64-bit integers, which is overkill when the original system only had 16-bit registers (despite being an 8-bit machine). This will probably lead to weird behaviour for programs that rely on hitting the ceiling for integers, so I'll go back and change it at some point. But for now, this is fine.
Next, we need to update the Command
enum to accept variables, which I'm storing as a tuple of (Variable name
, Variable value
).
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum Command {
Print(String),
GoTo(usize),
Var((String, Primitive)),
None,
}
First up, let's parse strings, because we've done that already for Print
and in theory, it should be simple.
use nom::combinator::{map, verify};
fn parse_str(i: &str) -> IResult<&str, (String, Primitive)> {
let (i, id) = verify(anychar, |c| c.is_alphabetic())(i)?;
let (i, _) = tag($)(i)?;
let (i, _) = tag(=)(i)?;
let (i, var) = map(read_string, Primitive::String)(i)?;
let var_name = format!({}$, id);
Ok((i, (var_name, var)))
}
So, first of all we verify that the variable name conforms to the standard - we read a single char
, and then use verify
to confirm that it's alphabetic. The next two characters are fixed, $
and =
, and then finally we re-use read_string
from our Print
parser, and map it to our Primitive::String
enum value. Then we just return the variable name and the value as a tuple.
Next, we want to parse numbers:
use nom::{
character::complete::{i64 as cci64, alphanumeric1, digit1},
combinator::{map, not}
}
fn parse_int(i: &str) -> IResult<&str, (String, Primitive)> {
let (i, _) = not(digit1)(i)?;
let (i, id) = alphanumeric1(i)?;
let (i, _) = tag(=)(i)?;
let (i, var) = map(cci64, Primitive::Int)(i)?;
Ok((i, (id.to_string(), var)))
}
Similar to parsing strings, we first check that the first character of the variable is not a digit, using the not
parser. Then we read one-or-more alphanumeric characters, check the assignment operator is there, and then read and map a 64-bit signed integer to our Primitive::Int
.
Finally, combine the two into a single parser using alt
:
fn parse_var(i: &str) -> IResult<&str, (String, Primitive)> {
alt((
parse_int,
parse_str
))(i)
}
For the final step, we need to update our Program
struct to store and handle variables. I'm lazy, so I'm going to use HashMap
and not do any real checks before inserting:
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Program {
nodes: Node,
current: Node,
vars: HashMap<String, Primitive>
}
pub fn execute(&mut self) {
let mut iter = self.clone();
while let Some(node) = iter.next() {
if let Node::Link { item, next: _ } = node {
match item.1 {
Command::Print(line) => println!({}, line),
Command::GoTo(line) => iter.jump_to_line(line),
Command::Var((id, var)) => {
self.vars.insert(id, var);
},
_ => panic!(Unrecognised command),
}
};
}
}
}
And that's it! We've not added any additional output, but putting a println
at the end of execute
does show variables being assigned:
10 PRINT Hello world
20 LET apples=5
30 LET b$=Hello
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/basic-interpreter`
Hello World
{apple: Int(10), b$: String(Hello)}
Next up, we'll update PRINT
so that it can print out a variable, and maybe perform simple operations on variables.