Back to blog

BASIC Interpreter Part Two: Variables

Part One

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.